feat: proposal delivery — email customers the PDF on Mark as Sent (#126)

* 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>
This commit is contained in:
Adam Moussa 2026-06-18 12:40:55 -04:00 • committed by GitHub
parent bddb3f0c37
commit 1fca0fa978
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1836 additions and 33 deletions

View file

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

View file

@ -2,6 +2,7 @@ using System.Text;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SimpleEmailV2;
using Amazon.SQS;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -28,6 +29,7 @@ if (!devMode)
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
builder.Services.AddAWSService<IAmazonSimpleEmailServiceV2>();
}
// Database
@ -126,11 +128,13 @@ if (devMode)
{
builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>();
builder.Services.AddScoped<IEmailService, DevEmailService>();
}
else
{
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IEmailService, SesEmailService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
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<ProposalDbContext>();
db.Database.Migrate();
await using var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
await using var lockCmd = connection.CreateCommand();
lockCmd.CommandText = "SELECT pg_advisory_lock(hashtext('ef_migrations'))";
await lockCmd.ExecuteNonQueryAsync();
try
{
db.Database.Migrate();
}
finally
{
await using var unlockCmd = connection.CreateCommand();
unlockCmd.CommandText = "SELECT pg_advisory_unlock(hashtext('ef_migrations'))";
await unlockCmd.ExecuteNonQueryAsync();
}
}
app.Run();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("ProductVersion", "8.0.27")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -67,6 +67,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<string>("ContactEmail")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,3 +60,30 @@ export interface CreateProposalRequest {
export interface UpdateLineItemsRequest {
lineItems: Omit<LineItem, 'id' | 'proposalId' | 'createdAt' | 'updatedAt'>[];
}
export interface Customer {
id: string;
name: string;
addresses: string[];
contactEmail: string | null;
createdAt: string;
}
export interface CreateCustomerRequest {
name: string;
addresses?: string[];
contactEmail?: string;
}
export interface UpdateCustomerRequest {
name?: string;
addresses?: string[];
contactEmail?: string | null;
}
export interface PagedResponse<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}

View file

@ -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() {
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></RoleGuard>} />
<Route path="/admin/users" element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>

View file

@ -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: <AdminPanelSettingsIcon fontSize="small" /> },
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
{ label: 'Customers', path: '/admin/customers', icon: <BusinessIcon fontSize="small" /> },
];
const sysadminNav = [

View file

@ -3,6 +3,7 @@ export const QUERY_KEYS = {
proposal: 'proposal',
proposalLineItems: 'proposalLineItems',
customers: 'customers',
customerList: 'customerList',
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',

View file

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

View file

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

View file

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

View file

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