mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 17:03:14 +00:00
* feat: proposal delivery — email customers the PDF on "Mark as Sent" Makes the system's namesake feature real: marking a proposal Sent now emails the customer an expiring link to the branded PDF, and customers are managed (with contact emails) instead of hardcoded. v1 PR4. API: - Customer.ContactEmail + migration; Customer list/update endpoints. Search stays additive at GET /api/customers?query= (frozen-mobile + web compat); new paginated list at GET /api/customers/list (admin). - IEmailService (SesEmailService v2 / DevEmailService, dev-gated). MarkSentAsync resolves the customer's email, presigns the latest PDF (7d), and sends via SES. Email/presign failures are caught + audited and NEVER roll back the Sent transition. - Startup EF migration guarded by a Postgres advisory lock (concurrency-safe). Infra: - SES email identity (proposals@seahavenind.com); least-privilege ses:SendEmail/ SendRawEmail scoped to the identity ARN + ses:FromAddress condition; SES_FROM_ADDRESS env. SES starts in sandbox — production access needed for unverified recipients. Web: - Customer management page (/admin/customers): list / create / edit incl. contact email. - New-proposal form searches real customers (free-solo) instead of a hardcoded value. - Mark-as-Sent dialog notes the PDF will be emailed to the customer. GPT-4.1 cross-review (SES IAM): no BLOCK (ses:FromAddress condition applied). Verified: api build + 121 tests; web tsc + 26 tests; infra tsc; ruff clean. * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Exposure of private information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
115 lines
3.7 KiB
C#
115 lines
3.7 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ProposalSystem.Application.DTOs;
|
|
using ProposalSystem.Application.Interfaces;
|
|
using ProposalSystem.Domain.Entities;
|
|
using ProposalSystem.Infrastructure.Data;
|
|
|
|
namespace ProposalSystem.Infrastructure.Services;
|
|
|
|
public class CustomerService : ICustomerService
|
|
{
|
|
private readonly ProposalDbContext _db;
|
|
|
|
public CustomerService(ProposalDbContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<CustomerResponse>> SearchAsync(string? query, CancellationToken ct = default)
|
|
{
|
|
var q = _db.Customers.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(query))
|
|
{
|
|
var search = query.ToLower();
|
|
q = q.Where(c => c.Name.ToLower().Contains(search));
|
|
}
|
|
|
|
var customers = await q
|
|
.OrderBy(c => c.Name)
|
|
.Take(50)
|
|
.ToListAsync(ct);
|
|
|
|
return customers.Select(MapToResponse).ToList();
|
|
}
|
|
|
|
public async Task<PagedResponse<CustomerResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
|
|
|
var query = _db.Customers.AsNoTracking().AsQueryable();
|
|
|
|
var totalCount = await query.CountAsync(ct);
|
|
|
|
var items = await query
|
|
.OrderBy(c => c.Name)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync(ct);
|
|
|
|
return new PagedResponse<CustomerResponse>(
|
|
items.Select(MapToResponse).ToList(),
|
|
totalCount,
|
|
page,
|
|
pageSize
|
|
);
|
|
}
|
|
|
|
public async Task<CustomerResponse> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var customer = new Customer
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = request.Name,
|
|
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
|
|
ContactEmail = request.ContactEmail,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
};
|
|
|
|
_db.Customers.Add(customer);
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
return MapToResponse(customer);
|
|
}
|
|
|
|
public async Task<CustomerResponse?> UpdateAsync(Guid id, UpdateCustomerRequest request, CancellationToken ct = default)
|
|
{
|
|
var customer = await _db.Customers.FindAsync(new object[] { id }, ct);
|
|
if (customer == null) return null;
|
|
|
|
if (request.Name != null)
|
|
customer.Name = request.Name;
|
|
|
|
if (request.Addresses != null)
|
|
customer.Addresses = JsonSerializer.Serialize(request.Addresses);
|
|
|
|
// ContactEmail is nullable — allow setting it to empty string to clear, or a value to set.
|
|
// Since UpdateCustomerRequest uses string?, we check if the property was explicitly provided.
|
|
// With records and JSON deserialization, null means "not provided" vs empty string means "clear".
|
|
if (request.ContactEmail != null)
|
|
customer.ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail;
|
|
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
return MapToResponse(customer);
|
|
}
|
|
|
|
private static CustomerResponse MapToResponse(Customer c) => new(
|
|
c.Id,
|
|
c.Name,
|
|
DeserializeAddresses(c.Addresses),
|
|
c.ContactEmail,
|
|
c.CreatedAt
|
|
);
|
|
|
|
private static List<string> DeserializeAddresses(string? json)
|
|
{
|
|
if (string.IsNullOrEmpty(json)) return new List<string>();
|
|
return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
|
|
}
|
|
}
|