diff --git a/api/src/ProposalSystem.Api/Controllers/CustomersController.cs b/api/src/ProposalSystem.Api/Controllers/CustomersController.cs index c4df356..2b01af7 100644 --- a/api/src/ProposalSystem.Api/Controllers/CustomersController.cs +++ b/api/src/ProposalSystem.Api/Controllers/CustomersController.cs @@ -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), 200)] public async Task>> 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), 200)] + public async Task>> 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> 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> Update( + Guid id, + [FromBody] UpdateCustomerRequest request, + CancellationToken ct) + { + var result = await _customerService.UpdateAsync(id, request, ct); + if (result == null) return NotFound(); + return Ok(result); + } } diff --git a/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs b/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs new file mode 100644 index 0000000..28617f3 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Controllers; + +/// +/// PR5: Pricing Library CRUD endpoints. Admins seed/manage reusable priced +/// reference items that feed the Bedrock RAG corpus. +/// +/// GET {id} is reachable by the internal API key middleware (which assigns +/// the "admins" role) so the library-ingest Lambda can fetch item details. +/// +[ApiController] +[Route("api/pricing-library")] +[Authorize] +public class PricingLibraryController : ControllerBase +{ + private readonly IPricingLibraryService _pricingLibraryService; + + public PricingLibraryController(IPricingLibraryService pricingLibraryService) + { + _pricingLibraryService = pricingLibraryService; + } + + [HttpGet("list")] + [Authorize(Roles = "admins,sysadmins")] + [ProducesResponseType(typeof(PagedResponse), 200)] + public async Task>> List( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 25, + CancellationToken ct = default) + { + var result = await _pricingLibraryService.ListAsync(page, pageSize, ct); + return Ok(result); + } + + /// + /// Get a single pricing library item. Accessible by admins/sysadmins role, + /// which includes internal Lambda callers (the internal API key middleware + /// assigns the "admins" role). + /// + [HttpGet("{id:guid}")] + [Authorize(Roles = "admins,sysadmins")] + [ProducesResponseType(typeof(PricingLibraryItemResponse), 200)] + [ProducesResponseType(404)] + public async Task> GetById( + Guid id, + CancellationToken ct) + { + var result = await _pricingLibraryService.GetByIdAsync(id, ct); + if (result == null) return NotFound(); + return Ok(result); + } + + [HttpPost] + [Authorize(Roles = "admins,sysadmins")] + [ProducesResponseType(typeof(PricingLibraryItemResponse), 201)] + [ProducesResponseType(400)] + public async Task> Create( + [FromBody] CreatePricingLibraryItemRequest request, + CancellationToken ct) + { + var result = await _pricingLibraryService.CreateAsync(request, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "admins,sysadmins")] + [ProducesResponseType(typeof(PricingLibraryItemResponse), 200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task> Update( + Guid id, + [FromBody] UpdatePricingLibraryItemRequest request, + CancellationToken ct) + { + var result = await _pricingLibraryService.UpdateAsync(id, request, ct); + if (result == null) return NotFound(); + return Ok(result); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "admins,sysadmins")] + [ProducesResponseType(204)] + [ProducesResponseType(404)] + public async Task Delete( + Guid id, + CancellationToken ct) + { + var deleted = await _pricingLibraryService.DeleteAsync(id, ct); + if (!deleted) return NotFound(); + return NoContent(); + } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index 3db61dc..0fac998 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -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(); builder.Services.AddAWSService(); builder.Services.AddAWSService(); + builder.Services.AddAWSService(); } // Database @@ -126,13 +128,16 @@ if (devMode) { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } else { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.AddScoped(); +builder.Services.AddScoped(); 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(); - 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(); diff --git a/api/src/ProposalSystem.Api/Services/DevEmailService.cs b/api/src/ProposalSystem.Api/Services/DevEmailService.cs new file mode 100644 index 0000000..7507b65 --- /dev/null +++ b/api/src/ProposalSystem.Api/Services/DevEmailService.cs @@ -0,0 +1,26 @@ +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Services; + +public class DevEmailService : IEmailService +{ + private readonly ILogger _logger; + + public DevEmailService(ILogger 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; + } +} diff --git a/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs b/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs index 3d8a994..b517544 100644 --- a/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs @@ -4,10 +4,18 @@ public record CustomerResponse( Guid Id, string Name, List Addresses, + string? ContactEmail, DateTime CreatedAt ); public record CreateCustomerRequest( string Name, - List? Addresses + List? Addresses, + string? ContactEmail +); + +public record UpdateCustomerRequest( + string? Name, + List? Addresses, + string? ContactEmail ); diff --git a/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs b/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs new file mode 100644 index 0000000..0dcec96 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs @@ -0,0 +1,38 @@ +using ProposalSystem.Domain.Entities; + +namespace ProposalSystem.Application.DTOs; + +/// +/// PR5: DTOs for the Pricing Library CRUD endpoints. +/// +public record PricingLibraryItemResponse( + Guid Id, + string Title, + string? Description, + ServiceCategory ServiceCategory, + string? Unit, + decimal? UnitPrice, + string? Keywords, + string Source, + DateTime CreatedAt, + DateTime UpdatedAt +); + +public record CreatePricingLibraryItemRequest( + string Title, + string? Description, + ServiceCategory ServiceCategory, + string? Unit, + decimal? UnitPrice, + string? Keywords, + string? Source +); + +public record UpdatePricingLibraryItemRequest( + string? Title, + string? Description, + ServiceCategory? ServiceCategory, + string? Unit, + decimal? UnitPrice, + string? Keywords +); diff --git a/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs b/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs index 6a22856..17cf792 100644 --- a/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs @@ -5,5 +5,7 @@ namespace ProposalSystem.Application.Interfaces; public interface ICustomerService { Task> SearchAsync(string? query, CancellationToken ct = default); + Task> ListAsync(int page, int pageSize, CancellationToken ct = default); Task CreateAsync(CreateCustomerRequest request, CancellationToken ct = default); + Task UpdateAsync(Guid id, UpdateCustomerRequest request, CancellationToken ct = default); } diff --git a/api/src/ProposalSystem.Application/Interfaces/IEmailService.cs b/api/src/ProposalSystem.Application/Interfaces/IEmailService.cs new file mode 100644 index 0000000..ece97a9 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IEmailService.cs @@ -0,0 +1,14 @@ +namespace ProposalSystem.Application.Interfaces; + +public interface IEmailService +{ + /// + /// Sends a proposal delivery email to the customer with a link to the PDF. + /// + Task SendProposalDeliveryAsync( + string toEmail, + string proposalNumber, + string customerName, + string pdfUrl, + CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs b/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs new file mode 100644 index 0000000..f6d011e --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs @@ -0,0 +1,15 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +/// +/// PR5: Service interface for Pricing Library CRUD operations. +/// +public interface IPricingLibraryService +{ + Task> ListAsync(int page, int pageSize, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default); + Task UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs b/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs index 959110f..9876d4f 100644 --- a/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs +++ b/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs @@ -10,5 +10,10 @@ public class CreateCustomerValidator : AbstractValidator 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)); } } diff --git a/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs b/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs new file mode 100644 index 0000000..512b9ff --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +/// +/// PR5: Validates CreatePricingLibraryItemRequest payloads. +/// +public class CreatePricingLibraryItemValidator : AbstractValidator +{ + public CreatePricingLibraryItemValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Title is required") + .MaximumLength(200); + + RuleFor(x => x.Description) + .MaximumLength(5000) + .When(x => x.Description != null); + + RuleFor(x => x.ServiceCategory).IsInEnum(); + + RuleFor(x => x.Unit) + .MaximumLength(50) + .When(x => x.Unit != null); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive") + .When(x => x.UnitPrice.HasValue); + + RuleFor(x => x.Keywords) + .MaximumLength(2000) + .When(x => x.Keywords != null); + + RuleFor(x => x.Source) + .MaximumLength(100) + .When(x => x.Source != null); + } +} diff --git a/api/src/ProposalSystem.Application/Validators/UpdateCustomerValidator.cs b/api/src/ProposalSystem.Application/Validators/UpdateCustomerValidator.cs new file mode 100644 index 0000000..6b53452 --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/UpdateCustomerValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +public class UpdateCustomerValidator : AbstractValidator +{ + 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)); + } +} diff --git a/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs b/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs new file mode 100644 index 0000000..ae236d0 --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +/// +/// PR5: Validates UpdatePricingLibraryItemRequest payloads. +/// +public class UpdatePricingLibraryItemValidator : AbstractValidator +{ + public UpdatePricingLibraryItemValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Title cannot be empty when provided") + .MaximumLength(200) + .When(x => x.Title != null); + + RuleFor(x => x.Description) + .MaximumLength(5000) + .When(x => x.Description != null); + + RuleFor(x => x.ServiceCategory) + .IsInEnum() + .When(x => x.ServiceCategory.HasValue); + + RuleFor(x => x.Unit) + .MaximumLength(50) + .When(x => x.Unit != null); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive") + .When(x => x.UnitPrice.HasValue); + + RuleFor(x => x.Keywords) + .MaximumLength(2000) + .When(x => x.Keywords != null); + } +} diff --git a/api/src/ProposalSystem.Domain/Entities/AuditLog.cs b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs index 1bfe81a..95fd976 100644 --- a/api/src/ProposalSystem.Domain/Entities/AuditLog.cs +++ b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs @@ -15,7 +15,8 @@ public enum AuditAction MarkSent, CreateRevision, UpdateRole, - ReturnToReview + ReturnToReview, + DeliverEmail } public class AuditLog diff --git a/api/src/ProposalSystem.Domain/Entities/Customer.cs b/api/src/ProposalSystem.Domain/Entities/Customer.cs index 468145d..f22727a 100644 --- a/api/src/ProposalSystem.Domain/Entities/Customer.cs +++ b/api/src/ProposalSystem.Domain/Entities/Customer.cs @@ -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; } } diff --git a/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs b/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs new file mode 100644 index 0000000..d6dbb19 --- /dev/null +++ b/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs @@ -0,0 +1,19 @@ +namespace ProposalSystem.Domain.Entities; + +/// +/// PR5: Reusable priced reference item for the Bedrock RAG pricing library. +/// Admins seed/manage these to enrich the corpus beyond Sent proposals. +/// +public class PricingLibraryItem +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public ServiceCategory ServiceCategory { get; set; } + public string? Unit { get; set; } + public decimal? UnitPrice { get; set; } + public string? Keywords { get; set; } + public string Source { get; set; } = "Manual"; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs new file mode 100644 index 0000000..356c029 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs @@ -0,0 +1,510 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.27") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("PoNumber") + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.cs new file mode 100644 index 0000000..c0b6a48 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + /// + public partial class AddCustomerContactEmail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContactEmail", + table: "Customers", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContactEmail", + table: "Customers"); + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs new file mode 100644 index 0000000..11d2674 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs @@ -0,0 +1,552 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProposalSystem.Infrastructure.Data; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ProposalDbContext))] + [Migration("20260612225458_AddPricingLibrary")] + partial class AddPricingLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.27") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingLibraryItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("PoNumber") + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs new file mode 100644 index 0000000..ee4b9ba --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + /// + public partial class AddPricingLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PricingLibraryItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + ServiceCategory = table.Column(type: "text", nullable: false), + Unit = table.Column(type: "text", nullable: true), + UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + Keywords = table.Column(type: "text", nullable: true), + Source = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PricingLibraryItems", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PricingLibraryItems"); + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs index a15f266..0aeb2a3 100644 --- a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs @@ -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("Addresses") .HasColumnType("jsonb"); + b.Property("ContactEmail") + .HasColumnType("text"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingLibraryItems"); + }); + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => { b.Property("Id") diff --git a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs index 70b882e..bc7aaf9 100644 --- a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs +++ b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs @@ -17,6 +17,7 @@ public class ProposalDbContext : DbContext public DbSet AuditLogs => Set(); public DbSet SimilarProposalReferences => Set(); public DbSet GeneratedPdfs => Set(); + public DbSet PricingLibraryItems => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -92,5 +93,13 @@ public class ProposalDbContext : DbContext entity.HasKey(e => e.Id); entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); }); + + // PR5: Pricing Library entity configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.UnitPrice).HasPrecision(18, 2); + entity.Property(e => e.ServiceCategory).HasConversion(); + }); } } diff --git a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj index 240132d..318d73f 100644 --- a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj +++ b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj @@ -16,6 +16,7 @@ + diff --git a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs index a0edf84..a617206 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs @@ -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> 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( + items.Select(MapToResponse).ToList(), + totalCount, + page, + pageSize + ); } public async Task 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(), - customer.CreatedAt - ); + return MapToResponse(customer); } + public async Task 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 DeserializeAddresses(string? json) { if (string.IsNullOrEmpty(json)) return new List(); diff --git a/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs b/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs new file mode 100644 index 0000000..c414250 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Infrastructure.Services; + +/// +/// PR5: Pricing Library CRUD service. Publishes "library-ingest" SQS jobs +/// on create/update so the Bedrock KB corpus stays in sync. +/// +public class PricingLibraryService : IPricingLibraryService +{ + private readonly ProposalDbContext _db; + private readonly IJobPublisher _jobPublisher; + private readonly ILogger _logger; + + public PricingLibraryService( + ProposalDbContext db, + IJobPublisher jobPublisher, + ILogger logger) + { + _db = db; + _jobPublisher = jobPublisher; + _logger = logger; + } + + public async Task> ListAsync(int page, int pageSize, CancellationToken ct = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); + + var query = _db.PricingLibraryItems.AsNoTracking().AsQueryable(); + + var totalCount = await query.CountAsync(ct); + + var items = await query + .OrderBy(i => i.Title) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(ct); + + return new PagedResponse( + items.Select(MapToResponse).ToList(), + totalCount, + page, + pageSize + ); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var item = await _db.PricingLibraryItems + .AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == id, ct); + + return item == null ? null : MapToResponse(item); + } + + public async Task CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = request.Title, + Description = request.Description, + ServiceCategory = request.ServiceCategory, + Unit = request.Unit, + UnitPrice = request.UnitPrice, + Keywords = request.Keywords, + Source = request.Source ?? "Manual", + CreatedAt = now, + UpdatedAt = now, + }; + + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(ct); + + _logger.LogInformation("PricingLibraryItem {ItemId} created with title '{Title}'", + item.Id, item.Title); + + // PR5: Trigger KB ingestion so the Bedrock RAG corpus picks up the new item. + // Wrap in try/catch so a publish failure does not roll back the save + // (matches the audit-no-rollback convention from API-H6). + try + { + await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id); + } + + return MapToResponse(item); + } + + public async Task UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default) + { + var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct); + if (item == null) return null; + + if (request.Title != null) + item.Title = request.Title; + + if (request.Description != null) + item.Description = request.Description; + + if (request.ServiceCategory.HasValue) + item.ServiceCategory = request.ServiceCategory.Value; + + if (request.Unit != null) + item.Unit = request.Unit; + + if (request.UnitPrice.HasValue) + item.UnitPrice = request.UnitPrice.Value; + + if (request.Keywords != null) + item.Keywords = request.Keywords; + + item.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + _logger.LogInformation("PricingLibraryItem {ItemId} updated", item.Id); + + // PR5: Trigger KB re-ingestion so the Bedrock RAG corpus reflects the update. + // Wrap in try/catch so a publish failure does not roll back the save + // (matches the audit-no-rollback convention from API-H6). + try + { + await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id); + } + + return MapToResponse(item); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct); + if (item == null) return false; + + _db.PricingLibraryItems.Remove(item); + await _db.SaveChangesAsync(ct); + + _logger.LogInformation("PricingLibraryItem {ItemId} deleted", item.Id); + + // TODO: PR5 follow-up — remove the corresponding markdown document from the + // Bedrock Knowledge Base S3 bucket so stale items are not returned by RAG queries. + + return true; + } + + private static PricingLibraryItemResponse MapToResponse(PricingLibraryItem item) => new( + item.Id, + item.Title, + item.Description, + item.ServiceCategory, + item.Unit, + item.UnitPrice, + item.Keywords, + item.Source, + item.CreatedAt, + item.UpdatedAt + ); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index 4d5ed07..a0448f7 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -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 _logger; public ProposalService( @@ -24,6 +28,9 @@ public class ProposalService : IProposalService IProposalNumberGenerator numberGenerator, IAuditService audit, IJobPublisher jobPublisher, + IEmailService emailService, + IS3Service s3, + IConfiguration config, ILogger 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); } + /// + /// 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. + /// + 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 ReviseAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals diff --git a/api/src/ProposalSystem.Infrastructure/Services/SesEmailService.cs b/api/src/ProposalSystem.Infrastructure/Services/SesEmailService.cs new file mode 100644 index 0000000..b1a1f38 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SesEmailService.cs @@ -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 _logger; + + public SesEmailService( + IAmazonSimpleEmailServiceV2 ses, + IConfiguration config, + ILogger 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 = $@" + + +

Dear {System.Net.WebUtility.HtmlEncode(customerName)},

+

Your proposal {System.Net.WebUtility.HtmlEncode(proposalNumber)} is ready for review.

+

+ View Proposal PDF +

+

This link will expire in 7 days. If you have any questions, please contact us directly.

+

Thank you,
Sea Haven Industries

+ +"; + + 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 { 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); + } +} diff --git a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs index b6e4a41..c17b354 100644 --- a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs +++ b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs @@ -172,6 +172,7 @@ public class AuthorizationAttributeTests [InlineData(typeof(GeneratedPdfsController))] [InlineData(typeof(FilesController))] [InlineData(typeof(VendorProposalsController))] + [InlineData(typeof(PricingLibraryController))] public void AllControllers_HaveAuthorizeAttribute(Type controllerType) { var attr = controllerType.GetCustomAttribute(); @@ -180,4 +181,47 @@ public class AuthorizationAttributeTests } #endregion + + #region PR5: PricingLibraryController Authorization + + [Fact(DisplayName = "PR5: PricingLibraryController requires authentication")] + public void PricingLibraryController_HasAuthorizeAttribute() + { + typeof(PricingLibraryController) + .GetCustomAttribute() + .Should().NotBeNull("PricingLibraryController should require authentication"); + } + + [Theory(DisplayName = "PR5: PricingLibrary admin actions require admins/sysadmins role")] + [InlineData("List")] + [InlineData("GetById")] + [InlineData("Create")] + [InlineData("Update")] + [InlineData("Delete")] + public void PricingLibraryController_AdminActions_RequireAdminRole(string methodName) + { + var method = typeof(PricingLibraryController).GetMethod(methodName); + method.Should().NotBeNull($"PricingLibraryController should have a {methodName} method"); + + var attr = method!.GetCustomAttribute(); + attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute"); + attr!.Roles.Should().NotBeNull($"{methodName} should specify roles"); + attr.Roles!.Split(',').Select(r => r.Trim()).Should() + .Contain("admins", $"{methodName} should allow admins (including internal Lambda callers)") + .And.Contain("sysadmins", $"{methodName} should allow sysadmins"); + } + + [Fact(DisplayName = "PR5: PricingLibrary GetById is reachable by internal Lambda callers (admins role)")] + public void PricingLibraryController_GetById_AllowsAdminsRole() + { + var method = typeof(PricingLibraryController).GetMethod("GetById"); + method.Should().NotBeNull(); + + var attr = method!.GetCustomAttribute(); + attr.Should().NotBeNull(); + attr!.Roles.Should().Contain("admins", + "GetById must be reachable by the internal API key middleware which assigns the 'admins' role"); + } + + #endregion } diff --git a/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs b/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs new file mode 100644 index 0000000..90113f4 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs @@ -0,0 +1,326 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Services; +using ProposalSystem.Tests.Helpers; +using Xunit; + +namespace ProposalSystem.Tests.Services; + +/// +/// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that +/// create/update publish "library-ingest" SQS jobs for KB sync. +/// +public class PricingLibraryServiceTests : IDisposable +{ + private readonly Infrastructure.Data.ProposalDbContext _db; + private readonly IJobPublisher _jobPublisher; + private readonly PricingLibraryService _sut; + + public PricingLibraryServiceTests() + { + _db = DbContextFactory.Create(); + _jobPublisher = Substitute.For(); + + _sut = new PricingLibraryService( + _db, + _jobPublisher, + Substitute.For>()); + } + + public void Dispose() + { + _db.Dispose(); + } + + #region Create + + [Fact(DisplayName = "PR5: Create returns item with generated Id and timestamps")] + public async Task CreateAsync_ReturnsItemWithIdAndTimestamps() + { + var request = new CreatePricingLibraryItemRequest( + Title: "HVAC Filter Replacement", + Description: "Standard filter replacement service", + ServiceCategory: ServiceCategory.HVAC, + Unit: "ea", + UnitPrice: 45.00m, + Keywords: "filter HVAC maintenance", + Source: "Manual" + ); + + var result = await _sut.CreateAsync(request); + + result.Id.Should().NotBe(Guid.Empty); + result.Title.Should().Be("HVAC Filter Replacement"); + result.Description.Should().Be("Standard filter replacement service"); + result.ServiceCategory.Should().Be(ServiceCategory.HVAC); + result.Unit.Should().Be("ea"); + result.UnitPrice.Should().Be(45.00m); + result.Keywords.Should().Be("filter HVAC maintenance"); + result.Source.Should().Be("Manual"); + result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact(DisplayName = "PR5: Create defaults Source to 'Manual' when null")] + public async Task CreateAsync_DefaultsSourceToManual() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Basic Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = await _sut.CreateAsync(request); + + result.Source.Should().Be("Manual"); + } + + [Fact(DisplayName = "PR5: Create publishes library-ingest job with pricingLibraryItemId")] + public async Task CreateAsync_PublishesLibraryIngestJob() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Test Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = await _sut.CreateAsync(request); + + await _jobPublisher.Received(1).PublishAsync( + "library-ingest", + Arg.Is(o => o.ToString()!.Contains(result.Id.ToString())), + Arg.Any()); + } + + [Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")] + public async Task CreateAsync_JobPublishFails_DoesNotRollBack() + { + _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new InvalidOperationException("SQS is down")); + + var request = new CreatePricingLibraryItemRequest( + Title: "Resilient Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = await _sut.CreateAsync(request); + + result.Title.Should().Be("Resilient Item"); + // Verify item was persisted despite job publish failure + var dbItem = await _db.PricingLibraryItems.FindAsync(result.Id); + dbItem.Should().NotBeNull(); + } + + #endregion + + #region Update + + [Fact(DisplayName = "PR5: Update modifies provided fields and publishes ingest job")] + public async Task UpdateAsync_ModifiesFieldsAndPublishesJob() + { + // Seed an item + var originalUpdatedAt = DateTime.UtcNow.AddDays(-1); + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Original Title", + Description = "Original", + ServiceCategory = ServiceCategory.General, + Unit = "ea", + UnitPrice = 10.00m, + Keywords = "original", + Source = "Manual", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = originalUpdatedAt, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Title", + Description: null, + ServiceCategory: ServiceCategory.Electrical, + Unit: null, + UnitPrice: 99.99m, + Keywords: null + ); + + var result = await _sut.UpdateAsync(item.Id, request); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Updated Title"); + result.ServiceCategory.Should().Be(ServiceCategory.Electrical); + result.UnitPrice.Should().Be(99.99m); + result.UpdatedAt.Should().BeAfter(originalUpdatedAt); + + await _jobPublisher.Received(1).PublishAsync( + "library-ingest", + Arg.Is(o => o.ToString()!.Contains(item.Id.ToString())), + Arg.Any()); + } + + [Fact(DisplayName = "PR5: Update returns null for non-existent item")] + public async Task UpdateAsync_NonExistentItem_ReturnsNull() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "Does Not Exist", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = await _sut.UpdateAsync(Guid.NewGuid(), request); + + result.Should().BeNull(); + } + + [Fact(DisplayName = "PR5: Update succeeds even when job publish fails (no rollback)")] + public async Task UpdateAsync_JobPublishFails_DoesNotRollBack() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Will Be Updated", + ServiceCategory = ServiceCategory.General, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new InvalidOperationException("SQS is down")); + + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Despite SQS Failure", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = await _sut.UpdateAsync(item.Id, request); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Updated Despite SQS Failure"); + } + + #endregion + + #region GetById + + [Fact(DisplayName = "PR5: GetById returns item when it exists")] + public async Task GetByIdAsync_ExistingItem_ReturnsItem() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Findable Item", + ServiceCategory = ServiceCategory.Plumbing, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var result = await _sut.GetByIdAsync(item.Id); + + result.Should().NotBeNull(); + result!.Id.Should().Be(item.Id); + result.Title.Should().Be("Findable Item"); + } + + [Fact(DisplayName = "PR5: GetById returns null for non-existent item")] + public async Task GetByIdAsync_NonExistentItem_ReturnsNull() + { + var result = await _sut.GetByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + + #endregion + + #region Delete + + [Fact(DisplayName = "PR5: Delete removes item and returns true")] + public async Task DeleteAsync_ExistingItem_RemovesAndReturnsTrue() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "To Be Deleted", + ServiceCategory = ServiceCategory.General, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var result = await _sut.DeleteAsync(item.Id); + + result.Should().BeTrue(); + var dbItem = await _db.PricingLibraryItems.FindAsync(item.Id); + dbItem.Should().BeNull(); + } + + [Fact(DisplayName = "PR5: Delete returns false for non-existent item")] + public async Task DeleteAsync_NonExistentItem_ReturnsFalse() + { + var result = await _sut.DeleteAsync(Guid.NewGuid()); + + result.Should().BeFalse(); + } + + #endregion + + #region List + + [Fact(DisplayName = "PR5: List returns paginated results ordered by title")] + public async Task ListAsync_ReturnsPaginatedResults() + { + // Seed 3 items + var items = new[] + { + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Charlie", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Alpha", ServiceCategory = ServiceCategory.HVAC, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Bravo", ServiceCategory = ServiceCategory.Plumbing, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + }; + _db.PricingLibraryItems.AddRange(items); + await _db.SaveChangesAsync(); + + var result = await _sut.ListAsync(1, 2); + + result.TotalCount.Should().Be(3); + result.Page.Should().Be(1); + result.PageSize.Should().Be(2); + result.Items.Should().HaveCount(2); + result.Items[0].Title.Should().Be("Alpha"); + result.Items[1].Title.Should().Be("Bravo"); + } + + #endregion +} diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs new file mode 100644 index 0000000..157dac2 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs @@ -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; + +/// +/// 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. +/// +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(); + _audit = Substitute.For(); + _jobPublisher = Substitute.For(); + _numberGenerator = Substitute.For(); + _emailService = Substitute.For(); + _s3Service = Substitute.For(); + + _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 + { + { "GENERATED_BUCKET", "test-bucket" }, + }) + .Build(); + + _sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher, + _emailService, _s3Service, config, + Substitute.For>()); + } + + 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(), Arg.Any(), Arg.Any()) + .Returns("https://s3.example.com/test.pdf"); + + // Make the email service throw + _emailService.SendProposalDeliveryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .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()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + 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), + }; + } +} diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs index 07cc69c..feea3f8 100644 --- a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs +++ b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs @@ -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(); _jobPublisher = Substitute.For(); _numberGenerator = Substitute.For(); + _emailService = Substitute.For(); + _s3Service = Substitute.For(); 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 + { + { "GENERATED_BUCKET", "test-bucket" }, + }) + .Build(); + _sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher, + _emailService, _s3Service, config, Substitute.For>()); } diff --git a/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs new file mode 100644 index 0000000..843b738 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs @@ -0,0 +1,239 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// PR5: Tests for CreatePricingLibraryItemValidator. +/// +public class CreatePricingLibraryItemValidatorTests +{ + private readonly CreatePricingLibraryItemValidator _sut = new(); + + [Fact(DisplayName = "PR5: Valid pricing library item passes validation")] + public void ValidRequest_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "HVAC Filter Replacement", + Description: "Standard 20x25x1 MERV-13 filter replacement", + ServiceCategory: ServiceCategory.HVAC, + Unit: "ea", + UnitPrice: 45.00m, + Keywords: "filter HVAC maintenance preventive", + Source: "Manual" + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Empty title fails validation")] + public void EmptyTitle_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Title exceeding 200 chars fails")] + public void Title_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: new string('X', 201), + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Description exceeding 5000 chars fails")] + public void Description_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: new string('X', 5001), + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Description"); + } + + [Fact(DisplayName = "PR5: Negative UnitPrice fails")] + public void NegativeUnitPrice_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.Plumbing, + Unit: "hr", + UnitPrice: -10.00m, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice"); + } + + [Fact(DisplayName = "PR5: Zero UnitPrice is valid")] + public void ZeroUnitPrice_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Complimentary Inspection", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: "ea", + UnitPrice: 0m, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Null UnitPrice is valid")] + public void NullUnitPrice_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Variable Price Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Keywords exceeding 2000 chars fails")] + public void Keywords_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: new string('X', 2001), + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Keywords"); + } + + [Fact(DisplayName = "PR5: Invalid ServiceCategory enum value fails")] + public void InvalidServiceCategory_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: (ServiceCategory)999, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory"); + } + + [Fact(DisplayName = "PR5: Unit exceeding 50 chars fails")] + public void Unit_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: new string('X', 51), + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Unit"); + } + + [Fact(DisplayName = "PR5: Source exceeding 100 chars fails")] + public void Source_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: new string('X', 101) + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Source"); + } + + [Fact(DisplayName = "PR5: Minimal valid request (title + category only) passes")] + public void MinimalValidRequest_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Basic Item", + Description: null, + ServiceCategory: ServiceCategory.Other, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } +} diff --git a/api/tests/ProposalSystem.Tests/Validators/CustomerEmailValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/CustomerEmailValidatorTests.cs new file mode 100644 index 0000000..a1fd644 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/CustomerEmailValidatorTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// 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. +/// +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 +} diff --git a/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs new file mode 100644 index 0000000..edabecc --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// PR5: Tests for UpdatePricingLibraryItemValidator. +/// +public class UpdatePricingLibraryItemValidatorTests +{ + private readonly UpdatePricingLibraryItemValidator _sut = new(); + + [Fact(DisplayName = "PR5: Valid update request passes")] + public void ValidRequest_Passes() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Title", + Description: "Updated description", + ServiceCategory: ServiceCategory.Electrical, + Unit: "hr", + UnitPrice: 125.50m, + Keywords: "electrical wiring" + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: All-null update request passes (no changes)")] + public void AllNullRequest_Passes() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Empty title when provided fails")] + public void EmptyTitle_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Title exceeding 200 chars fails")] + public void Title_TooLong_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: new string('X', 201), + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Negative UnitPrice fails")] + public void NegativeUnitPrice_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: -5.00m, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice"); + } + + [Fact(DisplayName = "PR5: Invalid ServiceCategory enum fails")] + public void InvalidServiceCategory_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: (ServiceCategory)999, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory"); + } +} diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts index 35af010..95b88b0 100644 --- a/infra/lib/compute-stack.ts +++ b/infra/lib/compute-stack.ts @@ -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 diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py index 796ad68..449c06f 100644 --- a/lambdas/library-ingest/app.py +++ b/lambdas/library-ingest/app.py @@ -1,8 +1,12 @@ """Proposal System - Library Ingest Lambda. -Processes approved/sent proposals into the Bedrock Knowledge Base library. -Formats proposal data as structured markdown and uploads to the library bucket, -then triggers a KB sync. +Processes approved/sent proposals and curated pricing library items into the +Bedrock Knowledge Base library. Formats data as structured markdown and uploads +to the library bucket, then triggers a KB sync. + +Supports two SQS message shapes (PR5): + - Proposal: {"jobType":"library-ingest","payload":{"proposalId":""}} + - Pricing item: {"jobType":"library-ingest","payload":{"pricingLibraryItemId":""}} """ import json @@ -101,8 +105,24 @@ def handler(event, context): try: payload = body.get("payload", body) - proposal_id = payload["proposalId"] - process_ingestion(proposal_id) + + # PR5: branch on payload shape — pricing item vs proposal. + pricing_item_id = payload.get("pricingLibraryItemId") + proposal_id = payload.get("proposalId") + if pricing_item_id and proposal_id: + logger.warning( + "Record %s has both pricingLibraryItemId and proposalId; " + "treating as a pricing item", + record.get("messageId"), + ) + if pricing_item_id: + process_pricing_item_ingestion(pricing_item_id) + elif proposal_id: + process_ingestion(proposal_id) + else: + raise ValueError( + "library-ingest payload has neither pricingLibraryItemId nor proposalId" + ) except Exception as e: # Fix: LAM-M5 — include stack trace in error logging logger.exception( @@ -128,6 +148,104 @@ def process_ingestion(proposal_id: str): trigger_kb_sync() +def process_pricing_item_ingestion(item_id: str): + """PR5: Ingest a curated pricing library item into the Bedrock Knowledge Base.""" + item = fetch_pricing_item(item_id) + if not item: + logger.warning("Pricing library item %s not found, skipping", item_id) + return + + document = format_pricing_item_document(item) + + s3_key = upload_pricing_item_to_library(item, document) + + if s3_key: + trigger_kb_sync() + + +def fetch_pricing_item(item_id: str) -> dict | None: + """PR5: Fetch a pricing library item from the .NET API.""" + try: + resp = _retry_request( + "GET", + f"{API_BASE_URL}/api/pricing-library/{item_id}", + headers=_api_headers(), + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + logger.exception("Error fetching pricing library item: %s", e) + return None + + +def format_pricing_item_document(item: dict) -> str: + """PR5: Format a pricing library item as a markdown document for Bedrock KB embedding.""" + unit_price = item.get("unitPrice") + price_str = f"${unit_price:,.2f}" if unit_price is not None else "-" + + keywords = item.get("keywords") or [] + keywords_str = ", ".join(keywords) if isinstance(keywords, list) else str(keywords) + + lines = [ + f"# Pricing Item: {item.get('title', '')}", + "", + f"**Service Category:** {item.get('serviceCategory', '')}", + f"**Unit:** {item.get('unit', '')}", + f"**Unit Price:** {price_str}", + f"**Source:** {item.get('source', '')}", + "", + "## Description", + "", + item.get("description", ""), + "", + "## Keywords", + "", + keywords_str, + ] + + return "\n".join(lines) + + +def upload_pricing_item_to_library(item: dict, document: str) -> str | None: + """PR5: Upload a formatted pricing item to the library S3 bucket. + + Uses the ``pricing-library/{category}/{item_id}.md`` key prefix so pricing + items never collide with proposal documents stored under ``proposals/``. + """ + if not LIBRARY_BUCKET: + logger.warning("No library bucket configured") + return None + + item_id = item.get("id", "unknown") + title = item.get("title", "unknown") + category = item.get("serviceCategory", "general") + s3_key = f"pricing-library/{category.lower()}/{item_id}.md" + + try: + s3_key = _validate_s3_key(s3_key) + except ValueError as e: + logger.error("Invalid S3 key for pricing item %s: %s", item_id, e) + return None + + try: + s3.put_object( + Bucket=LIBRARY_BUCKET, + Key=s3_key, + Body=document.encode("utf-8"), + ContentType="text/markdown", + Metadata={ + "service-category": category, + "pricing-item-title": title, + "pricing-item-id": str(item_id), + }, + ) + logger.info("Uploaded pricing item %s to library bucket at %s", item_id, s3_key) + return s3_key + except Exception as e: + logger.exception("Error uploading pricing item to library: %s", e) + return None + + def fetch_proposal(proposal_id: str) -> dict | None: try: resp = _retry_request( diff --git a/lambdas/tests/test_library_ingest.py b/lambdas/tests/test_library_ingest.py new file mode 100644 index 0000000..1c81ba7 --- /dev/null +++ b/lambdas/tests/test_library_ingest.py @@ -0,0 +1,235 @@ +"""Tests for library-ingest Lambda handler. + +PR5: Covers both the existing proposal ingestion path and the new pricing +library item ingestion path. +""" + +import importlib +import sys +import os +from unittest.mock import patch + +from helpers import make_sqs_event + +# Import library-ingest app under a unique module name (the directory name +# contains a hyphen so normal import won't work). +_library_ingest_dir = os.path.join(os.path.dirname(__file__), "..", "library-ingest") +_spec = importlib.util.spec_from_file_location( + "library_ingest_app", os.path.join(_library_ingest_dir, "app.py") +) +library_ingest_app = importlib.util.module_from_spec(_spec) +sys.modules["library_ingest_app"] = library_ingest_app +_spec.loader.exec_module(library_ingest_app) + + +# --------------------------------------------------------------------------- +# Sample data +# --------------------------------------------------------------------------- + +SAMPLE_PRICING_ITEM = { + "id": "item-abc-123", + "title": "Standard HVAC Filter Replacement", + "serviceCategory": "HVAC", + "unit": "each", + "unitPrice": 85.50, + "description": "Replace standard 20x25 HVAC filter with MERV-13 rated filter.", + "keywords": ["hvac", "filter", "replacement", "merv-13"], + "source": "PricingLibrary", +} + + +# --------------------------------------------------------------------------- +# Handler routing tests +# --------------------------------------------------------------------------- + + +class TestHandlerRouting: + """Verify the handler correctly routes pricing-item vs proposal messages.""" + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + def test_handler_routes_pricing_item_message(self, mock_process): + """PR5: pricingLibraryItemId in payload routes to pricing item path.""" + event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-abc-123"}}) + result = library_ingest_app.handler(event, None) + + mock_process.assert_called_once_with("item-abc-123") + assert result["batchItemFailures"] == [] + + @patch.object(library_ingest_app, "process_ingestion") + def test_handler_routes_proposal_message(self, mock_process): + """PR5: proposalId in payload still routes to existing proposal path.""" + event = make_sqs_event({"payload": {"proposalId": "prop-xyz-789"}}) + result = library_ingest_app.handler(event, None) + + mock_process.assert_called_once_with("prop-xyz-789") + assert result["batchItemFailures"] == [] + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + def test_handler_pricing_item_failure_goes_to_batch_failures(self, mock_process): + """PR5: Pricing item processing failure adds messageId to batchItemFailures.""" + mock_process.side_effect = RuntimeError("API unreachable") + event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-fail"}}) + result = library_ingest_app.handler(event, None) + + assert len(result["batchItemFailures"]) == 1 + assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-0" + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + @patch.object(library_ingest_app, "process_ingestion") + def test_handler_mixed_batch(self, mock_proposal, mock_pricing): + """PR5: A batch with both message types routes each correctly.""" + event = make_sqs_event( + {"payload": {"proposalId": "prop-1"}}, + {"payload": {"pricingLibraryItemId": "item-2"}}, + ) + result = library_ingest_app.handler(event, None) + + mock_proposal.assert_called_once_with("prop-1") + mock_pricing.assert_called_once_with("item-2") + assert result["batchItemFailures"] == [] + + +# --------------------------------------------------------------------------- +# Pricing item ingestion flow +# --------------------------------------------------------------------------- + + +class TestProcessPricingItemIngestion: + """Test the full pricing item ingestion pipeline.""" + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_full_flow(self, mock_fetch, mock_upload, mock_sync): + """PR5: Happy path fetches item, formats, uploads, and triggers sync.""" + mock_fetch.return_value = SAMPLE_PRICING_ITEM + mock_upload.return_value = "pricing-library/hvac/item-abc-123.md" + + library_ingest_app.process_pricing_item_ingestion("item-abc-123") + + mock_fetch.assert_called_once_with("item-abc-123") + mock_upload.assert_called_once() + mock_sync.assert_called_once() + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_skips_when_item_not_found(self, mock_fetch, mock_upload, mock_sync): + """PR5: Missing item logs warning and returns without failing the batch.""" + mock_fetch.return_value = None + + library_ingest_app.process_pricing_item_ingestion("nonexistent") + + mock_upload.assert_not_called() + mock_sync.assert_not_called() + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_skips_sync_when_upload_fails(self, mock_fetch, mock_upload, mock_sync): + """PR5: No KB sync when S3 upload returns None.""" + mock_fetch.return_value = SAMPLE_PRICING_ITEM + mock_upload.return_value = None + + library_ingest_app.process_pricing_item_ingestion("item-abc-123") + + mock_upload.assert_called_once() + mock_sync.assert_not_called() + + +# --------------------------------------------------------------------------- +# format_pricing_item_document +# --------------------------------------------------------------------------- + + +class TestFormatPricingItemDocument: + """Test markdown formatting for pricing items.""" + + def test_format_includes_all_fields(self): + """PR5: All pricing item fields appear in the formatted document.""" + doc = library_ingest_app.format_pricing_item_document(SAMPLE_PRICING_ITEM) + + assert "# Pricing Item: Standard HVAC Filter Replacement" in doc + assert "**Service Category:** HVAC" in doc + assert "**Unit:** each" in doc + assert "**Unit Price:** $85.50" in doc + assert "**Source:** PricingLibrary" in doc + assert "Replace standard 20x25 HVAC filter" in doc + assert "hvac, filter, replacement, merv-13" in doc + + def test_format_handles_missing_unit_price(self): + """PR5: None unitPrice renders as dash.""" + item = {**SAMPLE_PRICING_ITEM, "unitPrice": None} + doc = library_ingest_app.format_pricing_item_document(item) + + assert "**Unit Price:** -" in doc + + def test_format_handles_empty_keywords(self): + """PR5: Empty keywords list produces empty keywords section.""" + item = {**SAMPLE_PRICING_ITEM, "keywords": []} + doc = library_ingest_app.format_pricing_item_document(item) + + # Keywords section header exists, value is empty string + assert "## Keywords" in doc + + def test_format_handles_string_keywords(self): + """PR5: String keywords (non-list) are rendered as-is.""" + item = {**SAMPLE_PRICING_ITEM, "keywords": "hvac, filters"} + doc = library_ingest_app.format_pricing_item_document(item) + + assert "hvac, filters" in doc + + +# --------------------------------------------------------------------------- +# upload_pricing_item_to_library +# --------------------------------------------------------------------------- + + +class TestUploadPricingItemToLibrary: + """Test S3 upload for pricing items.""" + + @patch.object(library_ingest_app, "s3") + def test_upload_uses_pricing_library_prefix(self, mock_s3): + """PR5: S3 key uses pricing-library/ prefix, not proposals/.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "test-library-bucket" + try: + result = library_ingest_app.upload_pricing_item_to_library( + SAMPLE_PRICING_ITEM, "# Test doc" + ) + + assert result == "pricing-library/hvac/item-abc-123.md" + mock_s3.put_object.assert_called_once() + call_kwargs = mock_s3.put_object.call_args[1] + assert call_kwargs["Key"] == "pricing-library/hvac/item-abc-123.md" + assert call_kwargs["Bucket"] == "test-library-bucket" + assert call_kwargs["ContentType"] == "text/markdown" + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket + + def test_upload_returns_none_when_no_bucket(self): + """PR5: Returns None when LIBRARY_BUCKET is not configured.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "" + try: + result = library_ingest_app.upload_pricing_item_to_library( + SAMPLE_PRICING_ITEM, "# Test doc" + ) + assert result is None + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket + + @patch.object(library_ingest_app, "s3") + def test_upload_lowercases_category(self, mock_s3): + """PR5: Category in S3 key is lowercased.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "test-bucket" + item = {**SAMPLE_PRICING_ITEM, "serviceCategory": "Plumbing"} + try: + result = library_ingest_app.upload_pricing_item_to_library( + item, "# Test doc" + ) + assert result is not None + assert "pricing-library/plumbing/" in result + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 6c7fa49..a8c8b6b 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -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" diff --git a/mobile/package.json b/mobile/package.json index a55e7e2..9e95fe9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/shared/api-contracts/package-lock.json b/shared/api-contracts/package-lock.json new file mode 100644 index 0000000..8b30acd --- /dev/null +++ b/shared/api-contracts/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "devDependencies": { + "typescript": "~5.7.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 7575c72..6a14010 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -60,3 +60,63 @@ export interface CreateProposalRequest { export interface UpdateLineItemsRequest { lineItems: Omit[]; } + +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 { + 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; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 21f9d46..94a14ad 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 6d6d518..086e282 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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: }, { label: 'All Proposals', path: '/admin/proposals', icon: }, + { label: 'Customers', path: '/admin/customers', icon: }, + { label: 'Pricing Library', path: '/admin/pricing-library', icon: }, ]; const sysadminNav = [ diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 08cfb08..943ec10 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -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; diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts index 95d5f54..ddef320 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/lib/api/customers.ts @@ -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= — search autocomplete (unchanged). */ search: async (query?: string): Promise => { const params = query ? `?query=${encodeURIComponent(query)}` : ''; const res = await apiClient.get(`/customers${params}`); return res.data; }, - create: async (data: { name: string; addresses?: string[] }): Promise => { + /** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */ + list: async (params: CustomerListParams = {}): Promise => { + 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 => { 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 => { + const res = await apiClient.put(`/customers/${id}`, data); + return res.data; + }, }; diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts new file mode 100644 index 0000000..d477779 --- /dev/null +++ b/web/src/lib/api/pricingLibrary.ts @@ -0,0 +1,72 @@ +import apiClient from './client'; + +export interface PricingLibraryItem { + id: string; + title: string; + description?: string | null; + serviceCategory: string; + unit?: string | null; + unitPrice?: number | null; + keywords?: string | null; + source: string; + createdAt: string; + updatedAt: string; +} + +export interface PricingLibraryListParams { + page?: number; + pageSize?: number; +} + +export interface PagedPricingLibraryResponse { + items: PricingLibraryItem[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface CreatePricingLibraryItemRequest { + title: string; + serviceCategory: string; + description?: string; + unit?: string; + unitPrice?: number | null; + keywords?: string; +} + +export interface UpdatePricingLibraryItemRequest { + title: string; + serviceCategory: string; + description?: string; + unit?: string; + unitPrice?: number | null; + keywords?: string; +} + +export const pricingLibraryApi = { + /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ + list: async (params: PricingLibraryListParams = {}): Promise => { + const qs = new URLSearchParams(); + if (params.page) qs.append('page', String(params.page)); + if (params.pageSize) qs.append('pageSize', String(params.pageSize)); + const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`); + return res.data; + }, + + /** POST /api/pricing-library — create a new pricing library item. */ + create: async (data: CreatePricingLibraryItemRequest): Promise => { + const res = await apiClient.post('/pricing-library', data); + return res.data; + }, + + /** PUT /api/pricing-library/{id} — update an existing pricing library item. */ + update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => { + const res = await apiClient.put(`/pricing-library/${id}`, data); + return res.data; + }, + + /** DELETE /api/pricing-library/{id} — delete a pricing library item. */ + remove: async (id: string): Promise => { + await apiClient.delete(`/pricing-library/${id}`); + }, +}; diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx new file mode 100644 index 0000000..9248b98 --- /dev/null +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -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 | 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({ + 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(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formErrors, setFormErrors] = useState>>({}); + + 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> = {}; + 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 ( + + + Customer Management + + + + + + {/* Search bar */} + + handleSearchChange(e.target.value)} + sx={{ width: 300 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + {/* Error state */} + {isError && ( + } + action={ + + } + sx={{ mb: 2 }} + > + Failed to load customers. Please try again. + + )} + + {/* Table */} + + + + + Name + Address + Contact Email + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: COL_COUNT }).map((_, j) => ( + + + + ))} + + )) + : rows.map((customer) => ( + + {customer.name} + {customer.addresses?.[0] ?? '-'} + + {customer.contactEmail ? ( + customer.contactEmail + ) : ( + + No email + + )} + + + + openEditDialog(customer)} + > + + + + + + ))} + {!isLoading && rows.length === 0 && !isError && ( + + + + {debouncedSearch + ? 'No customers match your search.' + : 'No customers yet. Click "New Customer" to add one.'} + + + + )} + +
+
+ + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))} + rowsPerPageOptions={[12, 24, 48]} + /> +
+
+ + {/* Create / Edit Dialog */} + + {isEditing ? 'Edit Customer' : 'New Customer'} + + + handleFieldChange('name', e.target.value)} + error={!!formErrors.name} + helperText={formErrors.name} + size="small" + required + fullWidth + autoFocus + /> + handleFieldChange('address', e.target.value)} + error={!!formErrors.address} + helperText={formErrors.address} + size="small" + required + fullWidth + /> + handleFieldChange('contactEmail', e.target.value)} + error={!!formErrors.contactEmail} + helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'} + size="small" + fullWidth + /> + + + + + + + +
+ ); +} diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx new file mode 100644 index 0000000..95feec0 --- /dev/null +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -0,0 +1,479 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + TextField, + Button, + Skeleton, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + Chip, + MenuItem, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { toast } from 'react-toastify'; +import { + pricingLibraryApi, + type PricingLibraryItem, + type PagedPricingLibraryResponse, + type CreatePricingLibraryItemRequest, + type UpdatePricingLibraryItemRequest, +} from '../../../lib/api/pricingLibrary'; +import { queryClient } from '../../../lib/queryClient'; +import { QUERY_KEYS } from '../../../constants/queryKeys'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; +import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; + +const COL_COUNT = 6; // Title, Category, Unit, Unit Price, Source, Actions + +/** Chip color mapping for service categories. */ +const CATEGORY_CHIP_STYLES: Record = { + HVAC: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' }, + Plumbing: { bgcolor: '#DBEAFE', color: '#1E40AF', border: '1px solid #BFDBFE' }, + Electrical: { bgcolor: '#FEF9C3', color: '#854D0E', border: '1px solid #FDE68A' }, + General: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, + Renovation: { bgcolor: '#F3E8FF', color: '#6B21A8', border: '1px solid #C084FC' }, + Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }, +}; + +interface ItemFormState { + title: string; + serviceCategory: string; + unit: string; + unitPrice: string; + keywords: string; + description: string; +} + +const EMPTY_FORM: ItemFormState = { + title: '', + serviceCategory: '', + unit: '', + unitPrice: '', + keywords: '', + description: '', +}; + +/** Format a number as $X.XX or return "—" for null/undefined. */ +function formatPrice(value: number | null | undefined): string { + if (value == null) return '—'; + return `$${value.toFixed(2)}`; +} + +export default function PricingLibraryPage() { + useDocumentTitle('Pricing Library'); + + // --- List state --- + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { + data, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize], + queryFn: () => pricingLibraryApi.list({ page, pageSize }), + }); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + // --- Create/Edit dialog state --- + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formErrors, setFormErrors] = useState>>({}); + + const isEditing = editingItem !== null; + + const openCreateDialog = () => { + setEditingItem(null); + setForm(EMPTY_FORM); + setFormErrors({}); + setDialogOpen(true); + }; + + const openEditDialog = (item: PricingLibraryItem) => { + setEditingItem(item); + setForm({ + title: item.title, + serviceCategory: item.serviceCategory, + unit: item.unit ?? '', + unitPrice: item.unitPrice != null ? String(item.unitPrice) : '', + keywords: item.keywords ?? '', + description: item.description ?? '', + }); + setFormErrors({}); + setDialogOpen(true); + }; + + const closeDialog = () => { + setDialogOpen(false); + setEditingItem(null); + setForm(EMPTY_FORM); + setFormErrors({}); + }; + + const validateForm = (): boolean => { + const errors: Partial> = {}; + if (!form.title.trim()) errors.title = 'Title is required'; + if (!form.serviceCategory) errors.serviceCategory = 'Service category is required'; + if (form.unitPrice.trim() !== '') { + const parsed = parseFloat(form.unitPrice); + if (isNaN(parsed) || parsed < 0) { + errors.unitPrice = 'Must be a number >= 0'; + } + } + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleFieldChange = (field: keyof ItemFormState, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + if (formErrors[field]) { + setFormErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + // --- Delete confirm dialog state --- + const [deleteTarget, setDeleteTarget] = useState(null); + + const openDeleteDialog = (item: PricingLibraryItem) => { + setDeleteTarget(item); + }; + + const closeDeleteDialog = () => { + setDeleteTarget(null); + }; + + // --- Mutations --- + const createMutation = useMutation({ + mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDialog(); + toast.success('Pricing item created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create item: ${error.message}`); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => + pricingLibraryApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDialog(); + toast.success('Pricing item updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update item: ${error.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => pricingLibraryApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDeleteDialog(); + toast.success('Pricing item deleted'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete item: ${error.message}`); + }, + }); + + const handleSubmit = () => { + if (!validateForm()) return; + + const payload = { + title: form.title.trim(), + serviceCategory: form.serviceCategory, + ...(form.unit.trim() ? { unit: form.unit.trim() } : {}), + unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null, + ...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}), + ...(form.description.trim() ? { description: form.description.trim() } : {}), + }; + + if (isEditing) { + updateMutation.mutate({ id: editingItem.id, data: payload }); + } else { + createMutation.mutate(payload); + } + }; + + const handleDeleteConfirm = () => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget.id); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( + + + Pricing Library + + + + + + {/* Error state */} + {isError && ( + } + action={ + + } + sx={{ mb: 2 }} + > + Failed to load pricing library. Please try again. + + )} + + {/* Table */} + + + + + Title + Category + Unit + Unit Price + Source + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: COL_COUNT }).map((_, j) => ( + + + + ))} + + )) + : rows.map((item) => { + const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }; + const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip; + return ( + + {item.title} + + + + {item.unit ?? '—'} + + {formatPrice(item.unitPrice)} + + {item.source} + + + + openEditDialog(item)} + > + + + + + openDeleteDialog(item)} + color="error" + > + + + + + + + ); + })} + {!isLoading && rows.length === 0 && !isError && ( + + + + No pricing items yet. Click "New Item" to add one. + + + + )} + +
+
+ + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))} + rowsPerPageOptions={[12, 24, 48]} + /> +
+
+ + {/* Create / Edit Dialog */} + + {isEditing ? 'Edit Pricing Item' : 'New Pricing Item'} + + + handleFieldChange('title', e.target.value)} + error={!!formErrors.title} + helperText={formErrors.title} + size="small" + required + fullWidth + autoFocus + /> + handleFieldChange('serviceCategory', e.target.value)} + error={!!formErrors.serviceCategory} + helperText={formErrors.serviceCategory} + size="small" + required + fullWidth + select + > + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + handleFieldChange('unit', e.target.value)} + helperText="e.g., each, hour, sq ft" + size="small" + fullWidth + /> + handleFieldChange('unitPrice', e.target.value)} + error={!!formErrors.unitPrice} + helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'} + size="small" + fullWidth + slotProps={{ + htmlInput: { min: 0, step: '0.01' }, + }} + /> + handleFieldChange('keywords', e.target.value)} + helperText="Comma-separated keywords for AI matching." + size="small" + fullWidth + multiline + minRows={2} + /> + handleFieldChange('description', e.target.value)} + size="small" + fullWidth + multiline + minRows={2} + /> + + + + + + + + + {/* Delete Confirm Dialog */} + + Delete Pricing Item + + + Are you sure you want to delete {deleteTarget?.title}? This action cannot be undone. + + + + + + + +
+ ); +} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 7da0db5..ff32e7c 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -612,6 +612,10 @@ export default function AdminWorkspace() { Mark {proposal.proposalNumber} as sent to {proposal.customerName}? This action cannot be undone. + {/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */} + + The proposal PDF will be emailed to the customer's contact email on file, if one is set. + diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index eb466b2..713f49f 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -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({ workOrderNumber: '', - customerName: CUSTOMERS[0], + customerName: '', customerAddress: '', scopeOfWork: '', serviceCategory: '', @@ -63,9 +63,16 @@ export default function ProposalFormPage() { const [customCategory, setCustomCategory] = useState(''); const siteDebounceRef = useRef | null>(null); + // PR4: customer search state — freeSolo Autocomplete backed by customersApi.search + const [customerOptions, setCustomerOptions] = useState([]); + const [customerLoading, setCustomerLoading] = useState(false); + const [customerInputValue, setCustomerInputValue] = useState(''); + const customerDebounceRef = useRef | 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)} /> + {/* PR4: freeSolo customer search — dispatchers can pick an existing + customer or type a new name (customerName is a free string). */} - handleChange('customerName', e.target.value)} - > - {CUSTOMERS.map((name) => ( - - {name} - - ))} - + 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) => ( + + {customerLoading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + />