mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 08:53:15 +00:00
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:
parent
bddb3f0c37
commit
1fca0fa978
29 changed files with 1836 additions and 33 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
26
api/src/ProposalSystem.Api/Services/DevEmailService.cs
Normal file
26
api/src/ProposalSystem.Api/Services/DevEmailService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,8 @@ public enum AuditAction
|
|||
MarkSent,
|
||||
CreateRevision,
|
||||
UpdateRole,
|
||||
ReturnToReview
|
||||
ReturnToReview,
|
||||
DeliverEmail
|
||||
}
|
||||
|
||||
public class AuditLog
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
510
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs
generated
Normal file
510
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612222415_AddCustomerContactEmail.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
251
api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs
Normal file
251
api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const QUERY_KEYS = {
|
|||
proposal: 'proposal',
|
||||
proposalLineItems: 'proposalLineItems',
|
||||
customers: 'customers',
|
||||
customerList: 'customerList',
|
||||
users: 'users',
|
||||
dashboard: 'dashboard',
|
||||
auditTrail: 'auditTrail',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
375
web/src/pages/admin/customers/CustomerManagementPage.tsx
Normal file
375
web/src/pages/admin/customers/CustomerManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue