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

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

View file

@ -17,7 +17,10 @@ public class CustomersController : ControllerBase
_customerService = customerService; _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] [HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<CustomerResponse>), 200)]
public async Task<ActionResult<IReadOnlyList<CustomerResponse>>> Search( public async Task<ActionResult<IReadOnlyList<CustomerResponse>>> Search(
[FromQuery] string? query, [FromQuery] string? query,
CancellationToken ct) CancellationToken ct)
@ -26,7 +29,22 @@ public class CustomersController : ControllerBase
return Ok(result); 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] [HttpPost]
[ProducesResponseType(typeof(CustomerResponse), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<CustomerResponse>> Create( public async Task<ActionResult<CustomerResponse>> Create(
[FromBody] CreateCustomerRequest request, [FromBody] CreateCustomerRequest request,
CancellationToken ct) CancellationToken ct)
@ -34,4 +52,19 @@ public class CustomersController : ControllerBase
var result = await _customerService.CreateAsync(request, ct); var result = await _customerService.CreateAsync(request, ct);
return Created($"/api/customers/{result.Id}", result); return Created($"/api/customers/{result.Id}", result);
} }
[HttpPut("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(CustomerResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<CustomerResponse>> Update(
Guid id,
[FromBody] UpdateCustomerRequest request,
CancellationToken ct)
{
var result = await _customerService.UpdateAsync(id, request, ct);
if (result == null) return NotFound();
return Ok(result);
}
} }

View file

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

View file

@ -2,6 +2,7 @@ using System.Text;
using Amazon.DynamoDBv2; using Amazon.DynamoDBv2;
using Amazon.S3; using Amazon.S3;
using Amazon.SecretsManager; using Amazon.SecretsManager;
using Amazon.SimpleEmailV2;
using Amazon.SQS; using Amazon.SQS;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -28,6 +29,7 @@ if (!devMode)
builder.Services.AddAWSService<IAmazonSQS>(); builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>(); builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>(); builder.Services.AddAWSService<IAmazonDynamoDB>();
builder.Services.AddAWSService<IAmazonSimpleEmailServiceV2>();
} }
// Database // Database
@ -126,13 +128,16 @@ if (devMode)
{ {
builder.Services.AddScoped<IS3Service, DevS3Service>(); builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>(); builder.Services.AddScoped<ISiteService, DevSiteService>();
builder.Services.AddScoped<IEmailService, DevEmailService>();
} }
else else
{ {
builder.Services.AddScoped<IS3Service, S3Service>(); builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>(); builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IEmailService, SesEmailService>();
} }
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>(); builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
builder.Services.AddScoped<IPricingLibraryService, PricingLibraryService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl)) if (string.IsNullOrEmpty(jobsQueueUrl))
{ {
@ -240,10 +245,27 @@ app.Use(async (context, next) =>
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/api/health"); 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()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
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(); db.Database.Migrate();
}
finally
{
await using var unlockCmd = connection.CreateCommand();
unlockCmd.CommandText = "SELECT pg_advisory_unlock(hashtext('ef_migrations'))";
await unlockCmd.ExecuteNonQueryAsync();
}
} }
app.Run(); 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, Guid Id,
string Name, string Name,
List<string> Addresses, List<string> Addresses,
string? ContactEmail,
DateTime CreatedAt DateTime CreatedAt
); );
public record CreateCustomerRequest( public record CreateCustomerRequest(
string Name, string Name,
List<string>? Addresses List<string>? Addresses,
string? ContactEmail
);
public record UpdateCustomerRequest(
string? Name,
List<string>? Addresses,
string? ContactEmail
); );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" 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.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.SQS" Version="3.7.500.0" />
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.500.0" /> <PackageReference Include="AWSSDK.SecretsManager" Version="3.7.500.0" />
</ItemGroup> </ItemGroup>

View file

@ -31,12 +31,30 @@ public class CustomerService : ICustomerService
.Take(50) .Take(50)
.ToListAsync(ct); .ToListAsync(ct);
return customers.Select(c => new CustomerResponse( return customers.Select(MapToResponse).ToList();
c.Id, }
c.Name,
DeserializeAddresses(c.Addresses), public async Task<PagedResponse<CustomerResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default)
c.CreatedAt {
)).ToList(); 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) public async Task<CustomerResponse> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
@ -47,6 +65,7 @@ public class CustomerService : ICustomerService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = request.Name, Name = request.Name,
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null, Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
ContactEmail = request.ContactEmail,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
}; };
@ -54,14 +73,40 @@ public class CustomerService : ICustomerService
_db.Customers.Add(customer); _db.Customers.Add(customer);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return new CustomerResponse( return MapToResponse(customer);
customer.Id,
customer.Name,
request.Addresses ?? new List<string>(),
customer.CreatedAt
);
} }
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) private static List<string> DeserializeAddresses(string? json)
{ {
if (string.IsNullOrEmpty(json)) return new List<string>(); if (string.IsNullOrEmpty(json)) return new List<string>();

View file

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

View file

@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs; using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces; using ProposalSystem.Application.Interfaces;
@ -16,6 +17,9 @@ public class ProposalService : IProposalService
private readonly IProposalNumberGenerator _numberGenerator; private readonly IProposalNumberGenerator _numberGenerator;
private readonly IAuditService _audit; private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher; private readonly IJobPublisher _jobPublisher;
private readonly IEmailService _emailService;
private readonly IS3Service _s3;
private readonly IConfiguration _config;
private readonly ILogger<ProposalService> _logger; private readonly ILogger<ProposalService> _logger;
public ProposalService( public ProposalService(
@ -24,6 +28,9 @@ public class ProposalService : IProposalService
IProposalNumberGenerator numberGenerator, IProposalNumberGenerator numberGenerator,
IAuditService audit, IAuditService audit,
IJobPublisher jobPublisher, IJobPublisher jobPublisher,
IEmailService emailService,
IS3Service s3,
IConfiguration config,
ILogger<ProposalService> logger) ILogger<ProposalService> logger)
{ {
_db = db; _db = db;
@ -31,6 +38,9 @@ public class ProposalService : IProposalService
_numberGenerator = numberGenerator; _numberGenerator = numberGenerator;
_audit = audit; _audit = audit;
_jobPublisher = jobPublisher; _jobPublisher = jobPublisher;
_emailService = emailService;
_s3 = s3;
_config = config;
_logger = logger; _logger = logger;
} }
@ -328,11 +338,92 @@ public class ProposalService : IProposalService
_logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId); _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); await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct);
return MapToResponse(proposal); 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) public async Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam'; 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 s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns'; 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`, Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId, COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId, COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
SES_FROM_ADDRESS: 'proposals@seahavenind.com',
}, },
tracing: lambda.Tracing.ACTIVE, tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -221,6 +223,32 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn], 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). // 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) // 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 // must use SigV4 signing when calling this URL. The API key header alone is no longer

View file

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

View file

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

108
mobile/package-lock.json generated
View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,18 +4,63 @@ export interface Customer {
id: string; id: string;
name: string; name: string;
addresses: string[]; addresses: string[];
/** Contact email for proposal delivery. Null when not set. */
contactEmail?: string | null;
createdAt: string; 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 = { export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
search: async (query?: string): Promise<Customer[]> => { search: async (query?: string): Promise<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : ''; const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`); const res = await apiClient.get(`/customers${params}`);
return res.data; 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); const res = await apiClient.post('/customers', data);
return res.data; return res.data;
}, },
/** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */
update: async (id: string, data: UpdateCustomerRequest): Promise<Customer> => {
const res = await apiClient.put(`/customers/${id}`, data);
return res.data;
},
}; };

View file

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

View file

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

View file

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

View file

@ -612,6 +612,10 @@ export default function AdminWorkspace() {
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}? Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
This action cannot be undone. This action cannot be undone.
</Typography> </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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setSendDialogOpen(false)}>Cancel</Button> <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 { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals'; import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
import { sitesApi, type Site } from '../../../lib/api/sites'; import { sitesApi, type Site } from '../../../lib/api/sites';
import { customersApi } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
import { queryClient } from '../../../lib/queryClient'; import { queryClient } from '../../../lib/queryClient';
@ -33,7 +34,6 @@ interface ProposalFormState {
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf']; 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 // Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
const MIN_SCOPE_LENGTH = 10; const MIN_SCOPE_LENGTH = 10;
@ -42,7 +42,7 @@ export default function ProposalFormPage() {
const [form, setForm] = useState<ProposalFormState>({ const [form, setForm] = useState<ProposalFormState>({
workOrderNumber: '', workOrderNumber: '',
customerName: CUSTOMERS[0], customerName: '',
customerAddress: '', customerAddress: '',
scopeOfWork: '', scopeOfWork: '',
serviceCategory: '', serviceCategory: '',
@ -63,9 +63,16 @@ export default function ProposalFormPage() {
const [customCategory, setCustomCategory] = useState(''); const [customCategory, setCustomCategory] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 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(() => { useEffect(() => {
return () => { return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); 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({ const createMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const notes = form.serviceCategory === 'Other' && customCategory.trim() const notes = form.serviceCategory === 'Other' && customCategory.trim()
@ -203,21 +227,59 @@ export default function ProposalFormPage() {
onChange={(e) => handleChange('poNumber', e.target.value)} onChange={(e) => handleChange('poNumber', e.target.value)}
/> />
</Grid> </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 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<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 <TextField
{...params}
label="Customer" label="Customer"
required required
fullWidth placeholder="Search or type a customer name..."
select slotProps={{
value={form.customerName} input: {
onChange={(e) => handleChange('customerName', e.target.value)} ...params.InputProps,
> endAdornment: (
{CUSTOMERS.map((name) => ( <>
<MenuItem key={name} value={name}> {customerLoading && <CircularProgress size={20} />}
{name} {params.InputProps.endAdornment}
</MenuItem> </>
))} ),
</TextField> },
}}
/>
)}
/>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField <TextField