From 1fca0fa9786fe5fde055d7e4e7c50d50e5102dfc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:40:55 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20proposal=20delivery=20=E2=80=94=20email?= =?UTF-8?q?=20customers=20the=20PDF=20on=20Mark=20as=20Sent=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: proposal delivery — email customers the PDF on "Mark as Sent" Makes the system's namesake feature real: marking a proposal Sent now emails the customer an expiring link to the branded PDF, and customers are managed (with contact emails) instead of hardcoded. v1 PR4. API: - Customer.ContactEmail + migration; Customer list/update endpoints. Search stays additive at GET /api/customers?query= (frozen-mobile + web compat); new paginated list at GET /api/customers/list (admin). - IEmailService (SesEmailService v2 / DevEmailService, dev-gated). MarkSentAsync resolves the customer's email, presigns the latest PDF (7d), and sends via SES. Email/presign failures are caught + audited and NEVER roll back the Sent transition. - Startup EF migration guarded by a Postgres advisory lock (concurrency-safe). Infra: - SES email identity (proposals@seahavenind.com); least-privilege ses:SendEmail/ SendRawEmail scoped to the identity ARN + ses:FromAddress condition; SES_FROM_ADDRESS env. SES starts in sandbox — production access needed for unverified recipients. Web: - Customer management page (/admin/customers): list / create / edit incl. contact email. - New-proposal form searches real customers (free-solo) instead of a hardcoded value. - Mark-as-Sent dialog notes the PDF will be emailed to the customer. GPT-4.1 cross-review (SES IAM): no BLOCK (ses:FromAddress condition applied). Verified: api build + 121 tests; web tsc + 26 tests; infra tsc; ruff clean. * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Controllers/CustomersController.cs | 33 ++ api/src/ProposalSystem.Api/Program.cs | 23 +- .../Services/DevEmailService.cs | 26 + .../DTOs/CustomerDtos.cs | 10 +- .../Interfaces/ICustomerService.cs | 2 + .../Interfaces/IEmailService.cs | 14 + .../Validators/CreateCustomerValidator.cs | 5 + .../Validators/UpdateCustomerValidator.cs | 19 + .../Entities/AuditLog.cs | 3 +- .../Entities/Customer.cs | 1 + ...222415_AddCustomerContactEmail.Designer.cs | 510 ++++++++++++++++++ .../20260612222415_AddCustomerContactEmail.cs | 28 + .../ProposalDbContextModelSnapshot.cs | 5 +- .../ProposalSystem.Infrastructure.csproj | 1 + .../Services/CustomerService.cs | 69 ++- .../Services/ProposalService.cs | 91 ++++ .../Services/SesEmailService.cs | 86 +++ .../Services/ProposalDeliveryTests.cs | 251 +++++++++ .../Services/ProposalStateMachineTests.cs | 13 + .../Validators/CustomerEmailValidatorTests.cs | 99 ++++ infra/lib/compute-stack.ts | 28 + shared/api-contracts/src/index.ts | 27 + web/src/App.tsx | 2 + web/src/components/Sidebar.tsx | 2 + web/src/constants/queryKeys.ts | 1 + web/src/lib/api/customers.ts | 47 +- .../customers/CustomerManagementPage.tsx | 375 +++++++++++++ .../pages/admin/workspace/AdminWorkspace.tsx | 4 + .../pages/proposals/form/ProposalFormPage.tsx | 94 +++- 29 files changed, 1836 insertions(+), 33 deletions(-) create mode 100644 api/src/ProposalSystem.Api/Services/DevEmailService.cs create mode 100644 api/src/ProposalSystem.Application/Interfaces/IEmailService.cs create mode 100644 api/src/ProposalSystem.Application/Validators/UpdateCustomerValidator.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Services/SesEmailService.cs create mode 100644 api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs create mode 100644 api/tests/ProposalSystem.Tests/Validators/CustomerEmailValidatorTests.cs create mode 100644 web/src/pages/admin/customers/CustomerManagementPage.tsx 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/Program.cs b/api/src/ProposalSystem.Api/Program.cs index 3db61dc..fcb431e 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,11 +128,13 @@ if (devMode) { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } else { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.AddScoped(); var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; @@ -240,10 +244,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/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/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/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.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.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/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs index a15f266..7f964a1 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"); 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/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/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/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/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/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 7575c72..79de918 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -60,3 +60,30 @@ 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; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 21f9d46..e9b92ca 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ 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'; export default function App() { return ( @@ -46,6 +47,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 6d6d518..5a985ec 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ 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 { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; @@ -35,6 +36,7 @@ const dispatcherNav = [ const adminNav = [ { label: 'Admin Queue', path: '/admin', icon: }, { label: 'All Proposals', path: '/admin/proposals', icon: }, + { label: 'Customers', path: '/admin/customers', icon: }, ]; const sysadminNav = [ diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 08cfb08..8f8a824 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -3,6 +3,7 @@ export const QUERY_KEYS = { proposal: 'proposal', proposalLineItems: 'proposalLineItems', customers: 'customers', + customerList: 'customerList', users: 'users', dashboard: 'dashboard', auditTrail: 'auditTrail', 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/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/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} + + ), + }, + }} + /> + )} + />