mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
Implement Backend API Core (Phase 1)
Complete API layer with Clean Architecture: - Application DTOs (proposals, line items, customers, users, files, audit, dashboard) - Service interfaces (IProposalService, ILineItemService, ICustomerService, IAuditService, IS3Service, IJobPublisher) - FluentValidation validators for all create requests - Infrastructure service implementations (ProposalService, LineItemService, CustomerService, AuditService, S3Service, SqsJobPublisher, ProposalNumberGenerator) - Secrets Manager connection string resolver for RDS - API controllers: Proposals (CRUD + approve/send/revise), LineItems (CRUD + bulk), Customers, Users, Files (presigned upload/download), Admin (dashboard) - Middleware: GlobalExceptionHandler (ProblemDetails), ValidationFilter (FluentValidation pipeline) - CurrentUserService (Cognito JWT claims -> User entity, auto-provisioning) - Full DI configuration in Program.cs with Lambda hosting - Role-based authorization (dispatchers, admins, sysadmins) - CORS configured for proposals.seahaven.com + localhost
This commit is contained in:
parent
0b055b3ad9
commit
d2e12d3940
38 changed files with 1802 additions and 21 deletions
42
api/src/ProposalSystem.Api/Controllers/AdminController.cs
Normal file
42
api/src/ProposalSystem.Api/Controllers/AdminController.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
|
||||
public AdminController(ProposalDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<ActionResult<DashboardResponse>> GetDashboard(CancellationToken ct)
|
||||
{
|
||||
var pendingCount = await _db.Proposals
|
||||
.CountAsync(p => p.Status == ProposalStatus.InReview, ct);
|
||||
|
||||
var weekStart = DateTime.UtcNow.AddDays(-7);
|
||||
var approvedThisWeek = await _db.Proposals
|
||||
.CountAsync(p => p.ApprovedAt >= weekStart, ct);
|
||||
|
||||
var avgTurnaround = await _db.Proposals
|
||||
.Where(p => p.ApprovedAt.HasValue)
|
||||
.Select(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours)
|
||||
.DefaultIfEmpty(0)
|
||||
.AverageAsync(ct);
|
||||
|
||||
var totalProposals = await _db.Proposals.CountAsync(ct);
|
||||
|
||||
return Ok(new DashboardResponse(pendingCount, approvedThisWeek, avgTurnaround, totalProposals));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/customers")]
|
||||
[Authorize]
|
||||
public class CustomersController : ControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customerService;
|
||||
|
||||
public CustomersController(ICustomerService customerService)
|
||||
{
|
||||
_customerService = customerService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<CustomerResponse>>> Search(
|
||||
[FromQuery] string? query,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _customerService.SearchAsync(query, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<CustomerResponse>> Create(
|
||||
[FromBody] CreateCustomerRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _customerService.CreateAsync(request, ct);
|
||||
return Created($"/api/customers/{result.Id}", result);
|
||||
}
|
||||
}
|
||||
114
api/src/ProposalSystem.Api/Controllers/FilesController.cs
Normal file
114
api/src/ProposalSystem.Api/Controllers/FilesController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/proposals/{proposalId:guid}")]
|
||||
[Authorize]
|
||||
public class FilesController : ControllerBase
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
private readonly IS3Service _s3;
|
||||
private readonly IJobPublisher _jobPublisher;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public FilesController(
|
||||
ProposalDbContext db,
|
||||
IS3Service s3,
|
||||
IJobPublisher jobPublisher,
|
||||
IAuditService audit,
|
||||
IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_s3 = s3;
|
||||
_jobPublisher = jobPublisher;
|
||||
_audit = audit;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpPost("attachments")]
|
||||
public async Task<ActionResult<PresignedUploadResponse>> UploadAttachment(
|
||||
Guid proposalId,
|
||||
[FromQuery] string fileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
|
||||
if (proposal == null) return NotFound();
|
||||
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
if (extension != ".pdf")
|
||||
return BadRequest(new { error = "Only PDF files are accepted" });
|
||||
|
||||
var s3Key = $"vendors/{proposalId}/{Guid.NewGuid()}{extension}";
|
||||
var bucket = _config["UPLOADS_BUCKET"]!;
|
||||
|
||||
var url = await _s3.GeneratePresignedUploadUrlAsync(bucket, s3Key, "application/pdf");
|
||||
|
||||
var vendorProposal = new VendorProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposalId,
|
||||
FileName = fileName,
|
||||
S3Key = s3Key,
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
ProcessingStatus = ProcessingStatus.Pending,
|
||||
};
|
||||
|
||||
_db.VendorProposals.Add(vendorProposal);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _jobPublisher.PublishAsync("pdf-extract", new { proposalId, s3Key, vendorProposalId = vendorProposal.Id }, ct);
|
||||
|
||||
return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15)));
|
||||
}
|
||||
|
||||
[HttpGet("pdf")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
var pdf = await _db.GeneratedPdfs
|
||||
.Where(p => p.ProposalId == proposalId)
|
||||
.OrderByDescending(p => p.Revision)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (pdf == null)
|
||||
{
|
||||
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
|
||||
return Accepted(new { message = "PDF generation queued" });
|
||||
}
|
||||
|
||||
var bucket = _config["GENERATED_BUCKET"]!;
|
||||
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
|
||||
|
||||
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct);
|
||||
|
||||
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
|
||||
}
|
||||
|
||||
[HttpGet("pdf/{revision:int}")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
|
||||
Guid proposalId,
|
||||
int revision,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var pdf = await _db.GeneratedPdfs
|
||||
.FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct);
|
||||
|
||||
if (pdf == null) return NotFound();
|
||||
|
||||
var bucket = _config["GENERATED_BUCKET"]!;
|
||||
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
|
||||
|
||||
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {revision}", ct);
|
||||
|
||||
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get() => Ok(new { status = "healthy", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/proposals/{proposalId:guid}/line-items")]
|
||||
[Authorize]
|
||||
public class LineItemsController : ControllerBase
|
||||
{
|
||||
private readonly ILineItemService _lineItemService;
|
||||
|
||||
public LineItemsController(ILineItemService lineItemService)
|
||||
{
|
||||
_lineItemService = lineItemService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<LineItemResponse>>> GetAll(
|
||||
Guid proposalId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _lineItemService.GetByProposalIdAsync(proposalId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<LineItemResponse>> Create(
|
||||
Guid proposalId,
|
||||
[FromBody] CreateLineItemRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _lineItemService.CreateAsync(proposalId, request, ct);
|
||||
return Created($"/api/proposals/{proposalId}/line-items/{result.Id}", result);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<IReadOnlyList<LineItemResponse>>> BulkUpdate(
|
||||
Guid proposalId,
|
||||
[FromBody] BulkUpdateLineItemsRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _lineItemService.BulkUpdateAsync(proposalId, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemId:guid}")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<IActionResult> Delete(
|
||||
Guid proposalId,
|
||||
Guid itemId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _lineItemService.DeleteAsync(proposalId, itemId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
113
api/src/ProposalSystem.Api/Controllers/ProposalsController.cs
Normal file
113
api/src/ProposalSystem.Api/Controllers/ProposalsController.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/proposals")]
|
||||
[Authorize]
|
||||
public class ProposalsController : ControllerBase
|
||||
{
|
||||
private readonly IProposalService _proposalService;
|
||||
private readonly IJobPublisher _jobPublisher;
|
||||
|
||||
public ProposalsController(IProposalService proposalService, IJobPublisher jobPublisher)
|
||||
{
|
||||
_proposalService = proposalService;
|
||||
_jobPublisher = jobPublisher;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProposalResponse>> Create(
|
||||
[FromBody] CreateProposalRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResponse<ProposalListResponse>>> GetAll(
|
||||
[FromQuery] ProposalFilterRequest filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.GetAllAsync(filter, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ProposalResponse>> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.GetByIdAsync(id, ct);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<ProposalResponse>> Update(
|
||||
Guid id,
|
||||
[FromBody] UpdateProposalRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.UpdateAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<ProposalResponse>> Approve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.ApproveAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/send")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.MarkSentAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/revise")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<ProposalResponse>> Revise(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.ReviseAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/history")]
|
||||
public async Task<ActionResult<IReadOnlyList<ProposalResponse>>> GetHistory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.GetRevisionHistoryAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/audit")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> GetAudit(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _proposalService.GetAuditTrailAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/generate-suggestions")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<IActionResult> GenerateSuggestions(Guid id, CancellationToken ct)
|
||||
{
|
||||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "generate" }, ct);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/regenerate")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<IActionResult> Regenerate(Guid id, CancellationToken ct)
|
||||
{
|
||||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct);
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
65
api/src/ProposalSystem.Api/Controllers/UsersController.cs
Normal file
65
api/src/ProposalSystem.Api/Controllers/UsersController.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public UsersController(ProposalDbContext db, ICurrentUserService currentUser, IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserProfileResponse>> GetMe(CancellationToken ct)
|
||||
{
|
||||
var user = await _db.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == _currentUser.UserId, ct);
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
return Ok(new UserProfileResponse(user.Id, user.Email, user.DisplayName, user.Role));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "sysadmins")]
|
||||
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var users = await _db.Users
|
||||
.OrderBy(u => u.DisplayName)
|
||||
.Select(u => new UserResponse(u.Id, u.Email, u.DisplayName, u.Role, u.IsActive, u.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/role")]
|
||||
[Authorize(Roles = "sysadmins")]
|
||||
public async Task<IActionResult> UpdateRole(Guid id, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(new object[] { id }, ct);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
user.Role = request.Role;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.UpdateRole, null, $"User {user.Email} role changed to {request.Role}", ct);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ProposalSystem.Api.Middleware;
|
||||
|
||||
public class GlobalExceptionHandler : IMiddleware
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
var (statusCode, problemDetails) = exception switch
|
||||
{
|
||||
ValidationException validationEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 400,
|
||||
Title = "Validation Error",
|
||||
Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)),
|
||||
}
|
||||
),
|
||||
KeyNotFoundException => (
|
||||
HttpStatusCode.NotFound,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 404,
|
||||
Title = "Not Found",
|
||||
Detail = exception.Message,
|
||||
}
|
||||
),
|
||||
UnauthorizedAccessException => (
|
||||
HttpStatusCode.Unauthorized,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 401,
|
||||
Title = "Unauthorized",
|
||||
Detail = "Authentication required",
|
||||
}
|
||||
),
|
||||
InvalidOperationException => (
|
||||
HttpStatusCode.Conflict,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 409,
|
||||
Title = "Invalid Operation",
|
||||
Detail = exception.Message,
|
||||
}
|
||||
),
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = 500,
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An unexpected error occurred",
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
if (statusCode == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
_logger.LogError(exception, "Unhandled exception");
|
||||
}
|
||||
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
|
||||
await context.Response.WriteAsync(
|
||||
JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
}));
|
||||
}
|
||||
}
|
||||
53
api/src/ProposalSystem.Api/Middleware/ValidationFilter.cs
Normal file
53
api/src/ProposalSystem.Api/Middleware/ValidationFilter.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ProposalSystem.Api.Middleware;
|
||||
|
||||
public class ValidationFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public ValidationFilter(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
foreach (var argument in context.ActionArguments.Values)
|
||||
{
|
||||
if (argument == null) continue;
|
||||
|
||||
var argumentType = argument.GetType();
|
||||
var validatorType = typeof(IValidator<>).MakeGenericType(argumentType);
|
||||
|
||||
if (_serviceProvider.GetService(validatorType) is IValidator validator)
|
||||
{
|
||||
var validationContext = new ValidationContext<object>(argument);
|
||||
var result = await validator.ValidateAsync(validationContext);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var problemDetails = new ValidationProblemDetails(
|
||||
result.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
)
|
||||
)
|
||||
{
|
||||
Status = 400,
|
||||
Title = "Validation Error",
|
||||
};
|
||||
|
||||
context.Result = new BadRequestObjectResult(problemDetails);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,117 @@
|
|||
using Amazon.S3;
|
||||
using Amazon.SecretsManager;
|
||||
using Amazon.SQS;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ProposalSystem.Api.Middleware;
|
||||
using ProposalSystem.Api.Services;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Application.Validators;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
using ProposalSystem.Infrastructure.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
// AWS SDK clients
|
||||
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
||||
builder.Services.AddAWSService<IAmazonS3>();
|
||||
builder.Services.AddAWSService<IAmazonSQS>();
|
||||
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
||||
|
||||
builder.Services.AddDbContext<ProposalDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
// Database
|
||||
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
|
||||
if (!string.IsNullOrEmpty(dbSecretArn))
|
||||
{
|
||||
var smClient = new AmazonSecretsManagerClient();
|
||||
var connectionString = SecretsManagerConnectionString.ResolveAsync(smClient, dbSecretArn).GetAwaiter().GetResult();
|
||||
builder.Services.AddDbContext<ProposalDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddDbContext<ProposalDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Auth:Authority"];
|
||||
options.Audience = builder.Configuration["Auth:Audience"];
|
||||
});
|
||||
// Authentication
|
||||
var cognitoAuthority = builder.Configuration["Auth:Authority"];
|
||||
if (!string.IsNullOrEmpty(cognitoAuthority))
|
||||
{
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = cognitoAuthority;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
};
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Services
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IProposalService, ProposalService>();
|
||||
builder.Services.AddScoped<ILineItemService, LineItemService>();
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
||||
builder.Services.AddScoped<IS3Service, S3Service>();
|
||||
builder.Services.AddScoped<IJobPublisher>(sp =>
|
||||
{
|
||||
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
|
||||
var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
||||
return new SqsJobPublisher(sqsClient, queueUrl);
|
||||
});
|
||||
|
||||
// Validation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
||||
|
||||
// Controllers
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<ValidationFilter>();
|
||||
});
|
||||
|
||||
// Middleware
|
||||
builder.Services.AddTransient<GlobalExceptionHandler>();
|
||||
|
||||
// Health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<ProposalDbContext>();
|
||||
|
||||
// CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(
|
||||
"https://proposals.seahaven.com",
|
||||
"http://localhost:5173")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
// Lambda hosting
|
||||
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseMiddleware<GlobalExceptionHandler>();
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
|
|
|||
|
|
@ -17,8 +17,11 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Amazon.Lambda.AspNetCoreServer.Hosting" Version="1.7.2" />
|
||||
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.302" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
73
api/src/ProposalSystem.Api/Services/CurrentUserService.cs
Normal file
73
api/src/ProposalSystem.Api/Services/CurrentUserService.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Api.Services;
|
||||
|
||||
public class CurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContext;
|
||||
private readonly ProposalDbContext _db;
|
||||
private User? _cachedUser;
|
||||
|
||||
public CurrentUserService(IHttpContextAccessor httpContext, ProposalDbContext db)
|
||||
{
|
||||
_httpContext = httpContext;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public Guid UserId => GetUser().Id;
|
||||
public string Email => GetUser().Email;
|
||||
public UserRole Role => GetUser().Role;
|
||||
|
||||
public string? IpAddress =>
|
||||
_httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
private User GetUser()
|
||||
{
|
||||
if (_cachedUser != null) return _cachedUser;
|
||||
|
||||
var cognitoSub = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? _httpContext.HttpContext?.User.FindFirstValue("sub")
|
||||
?? throw new UnauthorizedAccessException("No authenticated user");
|
||||
|
||||
_cachedUser = _db.Users
|
||||
.FirstOrDefault(u => u.CognitoSub == cognitoSub);
|
||||
|
||||
if (_cachedUser == null)
|
||||
{
|
||||
var email = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
||||
?? _httpContext.HttpContext?.User.FindFirstValue("email")
|
||||
?? "unknown@seahaven.com";
|
||||
|
||||
var name = _httpContext.HttpContext?.User.FindFirstValue("name")
|
||||
?? email.Split('@')[0];
|
||||
|
||||
var groups = _httpContext.HttpContext?.User.FindAll("cognito:groups")
|
||||
.Select(c => c.Value).ToList() ?? new List<string>();
|
||||
|
||||
var role = groups.Contains("sysadmins") ? UserRole.SysAdmin
|
||||
: groups.Contains("admins") ? UserRole.Admin
|
||||
: UserRole.Dispatcher;
|
||||
|
||||
_cachedUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CognitoSub = cognitoSub,
|
||||
Email = email,
|
||||
DisplayName = name,
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.Users.Add(_cachedUser);
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
return _cachedUser;
|
||||
}
|
||||
}
|
||||
14
api/src/ProposalSystem.Application/DTOs/AuditDtos.cs
Normal file
14
api/src/ProposalSystem.Application/DTOs/AuditDtos.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record AuditLogResponse(
|
||||
Guid Id,
|
||||
Guid? ProposalId,
|
||||
Guid UserId,
|
||||
string? UserName,
|
||||
AuditAction Action,
|
||||
string? Details,
|
||||
DateTime Timestamp,
|
||||
string? IpAddress
|
||||
);
|
||||
13
api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs
Normal file
13
api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record CustomerResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
List<string> Addresses,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record CreateCustomerRequest(
|
||||
string Name,
|
||||
List<string>? Addresses
|
||||
);
|
||||
8
api/src/ProposalSystem.Application/DTOs/DashboardDtos.cs
Normal file
8
api/src/ProposalSystem.Application/DTOs/DashboardDtos.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record DashboardResponse(
|
||||
int PendingCount,
|
||||
int ApprovedThisWeek,
|
||||
double AvgTurnaroundHours,
|
||||
int TotalProposals
|
||||
);
|
||||
12
api/src/ProposalSystem.Application/DTOs/FileDtos.cs
Normal file
12
api/src/ProposalSystem.Application/DTOs/FileDtos.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record PresignedUploadResponse(
|
||||
string UploadUrl,
|
||||
string S3Key,
|
||||
DateTime ExpiresAt
|
||||
);
|
||||
|
||||
public record PdfDownloadResponse(
|
||||
string DownloadUrl,
|
||||
DateTime ExpiresAt
|
||||
);
|
||||
45
api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs
Normal file
45
api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record LineItemResponse(
|
||||
Guid Id,
|
||||
Guid ProposalId,
|
||||
string Description,
|
||||
decimal Quantity,
|
||||
string Unit,
|
||||
decimal? UnitPrice,
|
||||
decimal TotalPrice,
|
||||
PricingMode PricingMode,
|
||||
int SortOrder,
|
||||
LineItemSource Source,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
public record CreateLineItemRequest(
|
||||
string Description,
|
||||
decimal Quantity,
|
||||
string Unit,
|
||||
decimal? UnitPrice,
|
||||
decimal TotalPrice,
|
||||
PricingMode PricingMode,
|
||||
int SortOrder,
|
||||
LineItemSource Source
|
||||
);
|
||||
|
||||
public record BulkUpdateLineItemsRequest(
|
||||
List<UpdateLineItemEntry> LineItems
|
||||
);
|
||||
|
||||
public record UpdateLineItemEntry(
|
||||
Guid? Id,
|
||||
string Description,
|
||||
decimal Quantity,
|
||||
string Unit,
|
||||
decimal? UnitPrice,
|
||||
decimal TotalPrice,
|
||||
PricingMode PricingMode,
|
||||
int SortOrder,
|
||||
LineItemSource Source
|
||||
);
|
||||
78
api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs
Normal file
78
api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record CreateProposalRequest(
|
||||
string WorkOrderNumber,
|
||||
string CustomerName,
|
||||
string CustomerAddress,
|
||||
string ScopeOfWork,
|
||||
ServiceCategory ServiceCategory,
|
||||
Priority Priority,
|
||||
string? Notes
|
||||
);
|
||||
|
||||
public record UpdateProposalRequest(
|
||||
string? RefinedScope,
|
||||
string? Notes,
|
||||
Guid? AssignedAdminId
|
||||
);
|
||||
|
||||
public record ProposalResponse(
|
||||
Guid Id,
|
||||
string ProposalNumber,
|
||||
string WorkOrderNumber,
|
||||
string CustomerName,
|
||||
string CustomerAddress,
|
||||
string ScopeOfWork,
|
||||
string? RefinedScope,
|
||||
ServiceCategory ServiceCategory,
|
||||
Priority Priority,
|
||||
ProposalStatus Status,
|
||||
decimal TotalBidAmount,
|
||||
decimal? VendorTotalCost,
|
||||
string Notes,
|
||||
Guid SubmittedById,
|
||||
string? SubmittedByName,
|
||||
DateTime SubmittedAt,
|
||||
Guid? AssignedAdminId,
|
||||
Guid? ApprovedById,
|
||||
DateTime? ApprovedAt,
|
||||
DateTime? SentAt,
|
||||
int CurrentRevision,
|
||||
Guid? ParentProposalId,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
public record ProposalListResponse(
|
||||
Guid Id,
|
||||
string ProposalNumber,
|
||||
string CustomerName,
|
||||
string WorkOrderNumber,
|
||||
ServiceCategory ServiceCategory,
|
||||
Priority Priority,
|
||||
ProposalStatus Status,
|
||||
decimal TotalBidAmount,
|
||||
DateTime SubmittedAt,
|
||||
string? SubmittedByName,
|
||||
string? AssignedAdminName
|
||||
);
|
||||
|
||||
public record ProposalFilterRequest(
|
||||
ProposalStatus? Status,
|
||||
ServiceCategory? ServiceCategory,
|
||||
Priority? Priority,
|
||||
DateTime? FromDate,
|
||||
DateTime? ToDate,
|
||||
string? Search,
|
||||
int Page = 1,
|
||||
int PageSize = 25
|
||||
);
|
||||
|
||||
public record PagedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize
|
||||
);
|
||||
23
api/src/ProposalSystem.Application/DTOs/UserDtos.cs
Normal file
23
api/src/ProposalSystem.Application/DTOs/UserDtos.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.DTOs;
|
||||
|
||||
public record UserResponse(
|
||||
Guid Id,
|
||||
string Email,
|
||||
string DisplayName,
|
||||
UserRole Role,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record UpdateUserRoleRequest(
|
||||
UserRole Role
|
||||
);
|
||||
|
||||
public record UserProfileResponse(
|
||||
Guid Id,
|
||||
string Email,
|
||||
string DisplayName,
|
||||
UserRole Role
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface IAuditService
|
||||
{
|
||||
Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using ProposalSystem.Domain.Entities;
|
||||
|
||||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
Guid UserId { get; }
|
||||
string Email { get; }
|
||||
UserRole Role { get; }
|
||||
string? IpAddress { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<IReadOnlyList<CustomerResponse>> SearchAsync(string? query, CancellationToken ct = default);
|
||||
Task<CustomerResponse> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface IJobPublisher
|
||||
{
|
||||
Task PublishAsync(string jobType, object payload, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface ILineItemService
|
||||
{
|
||||
Task<IReadOnlyList<LineItemResponse>> GetByProposalIdAsync(Guid proposalId, CancellationToken ct = default);
|
||||
Task<LineItemResponse> CreateAsync(Guid proposalId, CreateLineItemRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<LineItemResponse>> BulkUpdateAsync(Guid proposalId, BulkUpdateLineItemsRequest request, CancellationToken ct = default);
|
||||
Task DeleteAsync(Guid proposalId, Guid lineItemId, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface IProposalNumberGenerator
|
||||
{
|
||||
Task<string> GenerateAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface IProposalService
|
||||
{
|
||||
Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default);
|
||||
Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
|
||||
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace ProposalSystem.Application.Interfaces;
|
||||
|
||||
public interface IS3Service
|
||||
{
|
||||
Task<string> GeneratePresignedUploadUrlAsync(string bucket, string key, string contentType, int expirationMinutes = 15);
|
||||
Task<string> GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using FluentValidation;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Validators;
|
||||
|
||||
public class CreateCustomerValidator : AbstractValidator<CreateCustomerRequest>
|
||||
{
|
||||
public CreateCustomerValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Customer name is required")
|
||||
.MaximumLength(200);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using FluentValidation;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Validators;
|
||||
|
||||
public class CreateLineItemValidator : AbstractValidator<CreateLineItemRequest>
|
||||
{
|
||||
public CreateLineItemValidator()
|
||||
{
|
||||
RuleFor(x => x.Description)
|
||||
.NotEmpty().WithMessage("Description is required")
|
||||
.MaximumLength(1000);
|
||||
|
||||
RuleFor(x => x.Quantity)
|
||||
.GreaterThan(0).WithMessage("Quantity must be positive");
|
||||
|
||||
RuleFor(x => x.Unit)
|
||||
.NotEmpty().WithMessage("Unit is required")
|
||||
.MaximumLength(50);
|
||||
|
||||
RuleFor(x => x.TotalPrice)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Total price cannot be negative");
|
||||
|
||||
RuleFor(x => x.UnitPrice)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.UnitPrice.HasValue)
|
||||
.WithMessage("Unit price cannot be negative");
|
||||
|
||||
RuleFor(x => x.PricingMode).IsInEnum();
|
||||
RuleFor(x => x.Source).IsInEnum();
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
using FluentValidation;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Validators;
|
||||
|
||||
public class CreateProposalValidator : AbstractValidator<CreateProposalRequest>
|
||||
{
|
||||
public CreateProposalValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkOrderNumber)
|
||||
.NotEmpty().WithMessage("Work order number is required")
|
||||
.MaximumLength(50);
|
||||
|
||||
RuleFor(x => x.CustomerName)
|
||||
.NotEmpty().WithMessage("Customer name is required")
|
||||
.MaximumLength(200);
|
||||
|
||||
RuleFor(x => x.CustomerAddress)
|
||||
.NotEmpty().WithMessage("Customer address is required")
|
||||
.MaximumLength(500);
|
||||
|
||||
RuleFor(x => x.ScopeOfWork)
|
||||
.NotEmpty().WithMessage("Scope of work is required")
|
||||
.MaximumLength(10000);
|
||||
|
||||
RuleFor(x => x.ServiceCategory).IsInEnum();
|
||||
RuleFor(x => x.Priority).IsInEnum();
|
||||
|
||||
RuleFor(x => x.Notes).MaximumLength(5000);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Infrastructure.Services;
|
||||
|
||||
public class AuditService : IAuditService
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public AuditService(ProposalDbContext db, ICurrentUserService currentUser)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default)
|
||||
{
|
||||
var entry = new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposalId,
|
||||
UserId = _currentUser.UserId,
|
||||
Action = action,
|
||||
Details = details,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = _currentUser.IpAddress,
|
||||
};
|
||||
|
||||
_db.AuditLogs.Add(entry);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
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(c => new CustomerResponse(
|
||||
c.Id,
|
||||
c.Name,
|
||||
DeserializeAddresses(c.Addresses),
|
||||
c.CreatedAt
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
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,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
_db.Customers.Add(customer);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new CustomerResponse(
|
||||
customer.Id,
|
||||
customer.Name,
|
||||
request.Addresses ?? new List<string>(),
|
||||
customer.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>();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
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 LineItemService : ILineItemService
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public LineItemService(ProposalDbContext db, IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LineItemResponse>> GetByProposalIdAsync(Guid proposalId, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.LineItems
|
||||
.Where(li => li.ProposalId == proposalId)
|
||||
.OrderBy(li => li.SortOrder)
|
||||
.Select(li => MapToResponse(li))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<LineItemResponse> CreateAsync(Guid proposalId, CreateLineItemRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
|
||||
|
||||
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
|
||||
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lineItem = new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposalId,
|
||||
Description = request.Description,
|
||||
Quantity = request.Quantity,
|
||||
Unit = request.Unit,
|
||||
UnitPrice = request.UnitPrice,
|
||||
TotalPrice = request.TotalPrice,
|
||||
PricingMode = request.PricingMode,
|
||||
SortOrder = request.SortOrder,
|
||||
Source = request.Source,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
_db.LineItems.Add(lineItem);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
|
||||
|
||||
return MapToResponse(lineItem);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LineItemResponse>> BulkUpdateAsync(Guid proposalId, BulkUpdateLineItemsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
|
||||
|
||||
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
|
||||
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
|
||||
|
||||
var existing = await _db.LineItems
|
||||
.Where(li => li.ProposalId == proposalId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
_db.LineItems.RemoveRange(existing);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var newItems = request.LineItems.Select(entry => new LineItem
|
||||
{
|
||||
Id = entry.Id ?? Guid.NewGuid(),
|
||||
ProposalId = proposalId,
|
||||
Description = entry.Description,
|
||||
Quantity = entry.Quantity,
|
||||
Unit = entry.Unit,
|
||||
UnitPrice = entry.UnitPrice,
|
||||
TotalPrice = entry.TotalPrice,
|
||||
PricingMode = entry.PricingMode,
|
||||
SortOrder = entry.SortOrder,
|
||||
Source = entry.Source,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
}).ToList();
|
||||
|
||||
_db.LineItems.AddRange(newItems);
|
||||
|
||||
proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice);
|
||||
proposal.UpdatedAt = now;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
|
||||
|
||||
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid proposalId, Guid lineItemId, CancellationToken ct = default)
|
||||
{
|
||||
var lineItem = await _db.LineItems
|
||||
.FirstOrDefaultAsync(li => li.Id == lineItemId && li.ProposalId == proposalId, ct)
|
||||
?? throw new KeyNotFoundException($"Line item {lineItemId} not found");
|
||||
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)!;
|
||||
if (proposal!.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
|
||||
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
|
||||
|
||||
_db.LineItems.Remove(lineItem);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Removed: {lineItem.Description}", ct);
|
||||
}
|
||||
|
||||
private static LineItemResponse MapToResponse(LineItem li) => new(
|
||||
li.Id,
|
||||
li.ProposalId,
|
||||
li.Description,
|
||||
li.Quantity,
|
||||
li.Unit,
|
||||
li.UnitPrice,
|
||||
li.TotalPrice,
|
||||
li.PricingMode,
|
||||
li.SortOrder,
|
||||
li.Source,
|
||||
li.CreatedAt,
|
||||
li.UpdatedAt
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Infrastructure.Services;
|
||||
|
||||
public class ProposalNumberGenerator : IProposalNumberGenerator
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
|
||||
public ProposalNumberGenerator(ProposalDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(CancellationToken ct = default)
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var prefix = $"SHI-{year}-";
|
||||
|
||||
var lastNumber = await _db.Proposals
|
||||
.Where(p => p.ProposalNumber.StartsWith(prefix))
|
||||
.OrderByDescending(p => p.ProposalNumber)
|
||||
.Select(p => p.ProposalNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
int nextSequence = 1;
|
||||
if (lastNumber != null)
|
||||
{
|
||||
var sequencePart = lastNumber[prefix.Length..];
|
||||
if (int.TryParse(sequencePart, out var current))
|
||||
{
|
||||
nextSequence = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $"{prefix}{nextSequence:D4}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
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 ProposalService : IProposalService
|
||||
{
|
||||
private readonly ProposalDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IProposalNumberGenerator _numberGenerator;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly IJobPublisher _jobPublisher;
|
||||
|
||||
public ProposalService(
|
||||
ProposalDbContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IProposalNumberGenerator numberGenerator,
|
||||
IAuditService audit,
|
||||
IJobPublisher jobPublisher)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
_numberGenerator = numberGenerator;
|
||||
_audit = audit;
|
||||
_jobPublisher = jobPublisher;
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var proposalNumber = await _numberGenerator.GenerateAsync(ct);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var proposal = new Proposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalNumber = proposalNumber,
|
||||
WorkOrderNumber = request.WorkOrderNumber,
|
||||
CustomerName = request.CustomerName,
|
||||
CustomerAddress = request.CustomerAddress,
|
||||
ScopeOfWork = request.ScopeOfWork,
|
||||
ServiceCategory = request.ServiceCategory,
|
||||
Priority = request.Priority,
|
||||
Status = ProposalStatus.Draft,
|
||||
Notes = request.Notes ?? string.Empty,
|
||||
SubmittedById = _currentUser.UserId,
|
||||
SubmittedAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
|
||||
|
||||
await _jobPublisher.PublishAsync("pdf-extract", new { proposalId = proposal.Id }, ct);
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals
|
||||
.Include(p => p.SubmittedBy)
|
||||
.Include(p => p.AssignedAdmin)
|
||||
.Include(p => p.ApprovedBy)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
|
||||
return proposal == null ? null : MapToResponse(proposal);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default)
|
||||
{
|
||||
var query = _db.Proposals
|
||||
.Include(p => p.SubmittedBy)
|
||||
.Include(p => p.AssignedAdmin)
|
||||
.AsQueryable();
|
||||
|
||||
if (_currentUser.Role == UserRole.Dispatcher)
|
||||
{
|
||||
query = query.Where(p => p.SubmittedById == _currentUser.UserId);
|
||||
}
|
||||
|
||||
if (filter.Status.HasValue)
|
||||
query = query.Where(p => p.Status == filter.Status.Value);
|
||||
|
||||
if (filter.ServiceCategory.HasValue)
|
||||
query = query.Where(p => p.ServiceCategory == filter.ServiceCategory.Value);
|
||||
|
||||
if (filter.Priority.HasValue)
|
||||
query = query.Where(p => p.Priority == filter.Priority.Value);
|
||||
|
||||
if (filter.FromDate.HasValue)
|
||||
query = query.Where(p => p.SubmittedAt >= filter.FromDate.Value);
|
||||
|
||||
if (filter.ToDate.HasValue)
|
||||
query = query.Where(p => p.SubmittedAt <= filter.ToDate.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Search))
|
||||
{
|
||||
var search = filter.Search.ToLower();
|
||||
query = query.Where(p =>
|
||||
p.CustomerName.ToLower().Contains(search) ||
|
||||
p.ProposalNumber.ToLower().Contains(search) ||
|
||||
p.WorkOrderNumber.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(p => p.Priority)
|
||||
.ThenByDescending(p => p.SubmittedAt)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.Select(p => new ProposalListResponse(
|
||||
p.Id,
|
||||
p.ProposalNumber,
|
||||
p.CustomerName,
|
||||
p.WorkOrderNumber,
|
||||
p.ServiceCategory,
|
||||
p.Priority,
|
||||
p.Status,
|
||||
p.TotalBidAmount,
|
||||
p.SubmittedAt,
|
||||
p.SubmittedBy != null ? p.SubmittedBy.DisplayName : null,
|
||||
p.AssignedAdmin != null ? p.AssignedAdmin.DisplayName : null
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResponse<ProposalListResponse>(items, totalCount, filter.Page, filter.PageSize);
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {id} not found");
|
||||
|
||||
if (request.RefinedScope != null)
|
||||
proposal.RefinedScope = request.RefinedScope;
|
||||
|
||||
if (request.Notes != null)
|
||||
proposal.Notes = request.Notes;
|
||||
|
||||
if (request.AssignedAdminId.HasValue)
|
||||
proposal.AssignedAdminId = request.AssignedAdminId.Value;
|
||||
|
||||
proposal.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.Edit, id, null, ct);
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals
|
||||
.Include(p => p.LineItems)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {id} not found");
|
||||
|
||||
if (proposal.Status != ProposalStatus.InReview)
|
||||
throw new InvalidOperationException("Only proposals in review can be approved");
|
||||
|
||||
if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0))
|
||||
throw new InvalidOperationException("Cannot approve proposal without priced line items");
|
||||
|
||||
proposal.Status = ProposalStatus.Approved;
|
||||
proposal.ApprovedById = _currentUser.UserId;
|
||||
proposal.ApprovedAt = DateTime.UtcNow;
|
||||
proposal.TotalBidAmount = proposal.LineItems.Sum(li => li.TotalPrice);
|
||||
proposal.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _audit.LogAsync(AuditAction.Approve, id, null, ct);
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {id} not found");
|
||||
|
||||
if (proposal.Status != ProposalStatus.Approved)
|
||||
throw new InvalidOperationException("Only approved proposals can be marked as sent");
|
||||
|
||||
proposal.Status = ProposalStatus.Sent;
|
||||
proposal.SentAt = DateTime.UtcNow;
|
||||
proposal.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _audit.LogAsync(AuditAction.MarkSent, id, null, ct);
|
||||
|
||||
await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct);
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
public async Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals
|
||||
.Include(p => p.LineItems)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {id} not found");
|
||||
|
||||
if (proposal.Status != ProposalStatus.Sent)
|
||||
throw new InvalidOperationException("Only sent proposals can be revised");
|
||||
|
||||
var revision = new Proposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalNumber = proposal.ProposalNumber,
|
||||
WorkOrderNumber = proposal.WorkOrderNumber,
|
||||
CustomerName = proposal.CustomerName,
|
||||
CustomerAddress = proposal.CustomerAddress,
|
||||
ScopeOfWork = proposal.ScopeOfWork,
|
||||
RefinedScope = proposal.RefinedScope,
|
||||
ServiceCategory = proposal.ServiceCategory,
|
||||
Priority = proposal.Priority,
|
||||
Status = ProposalStatus.InReview,
|
||||
Notes = proposal.Notes,
|
||||
SubmittedById = proposal.SubmittedById,
|
||||
SubmittedAt = proposal.SubmittedAt,
|
||||
AssignedAdminId = _currentUser.UserId,
|
||||
CurrentRevision = proposal.CurrentRevision + 1,
|
||||
ParentProposalId = proposal.Id,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
foreach (var li in proposal.LineItems)
|
||||
{
|
||||
revision.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = revision.Id,
|
||||
Description = li.Description,
|
||||
Quantity = li.Quantity,
|
||||
Unit = li.Unit,
|
||||
UnitPrice = li.UnitPrice,
|
||||
TotalPrice = li.TotalPrice,
|
||||
PricingMode = li.PricingMode,
|
||||
SortOrder = li.SortOrder,
|
||||
Source = li.Source,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
proposal.Status = ProposalStatus.Revised;
|
||||
proposal.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
_db.Proposals.Add(revision);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, $"Revised from {proposal.Id}", ct);
|
||||
|
||||
return MapToResponse(revision);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {id} not found");
|
||||
|
||||
var rootId = proposal.ParentProposalId ?? proposal.Id;
|
||||
|
||||
var revisions = await _db.Proposals
|
||||
.Where(p => p.Id == rootId || p.ParentProposalId == rootId)
|
||||
.OrderBy(p => p.CurrentRevision)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return revisions.Select(MapToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.AuditLogs
|
||||
.Include(a => a.User)
|
||||
.Where(a => a.ProposalId == id)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Select(a => new AuditLogResponse(
|
||||
a.Id,
|
||||
a.ProposalId,
|
||||
a.UserId,
|
||||
a.User != null ? a.User.DisplayName : null,
|
||||
a.Action,
|
||||
a.Details,
|
||||
a.Timestamp,
|
||||
a.IpAddress
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static ProposalResponse MapToResponse(Proposal p) => new(
|
||||
p.Id,
|
||||
p.ProposalNumber,
|
||||
p.WorkOrderNumber,
|
||||
p.CustomerName,
|
||||
p.CustomerAddress,
|
||||
p.ScopeOfWork,
|
||||
p.RefinedScope,
|
||||
p.ServiceCategory,
|
||||
p.Priority,
|
||||
p.Status,
|
||||
p.TotalBidAmount,
|
||||
p.VendorTotalCost,
|
||||
p.Notes,
|
||||
p.SubmittedById,
|
||||
p.SubmittedBy?.DisplayName,
|
||||
p.SubmittedAt,
|
||||
p.AssignedAdminId,
|
||||
p.ApprovedById,
|
||||
p.ApprovedAt,
|
||||
p.SentAt,
|
||||
p.CurrentRevision,
|
||||
p.ParentProposalId,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt
|
||||
);
|
||||
}
|
||||
44
api/src/ProposalSystem.Infrastructure/Services/S3Service.cs
Normal file
44
api/src/ProposalSystem.Infrastructure/Services/S3Service.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
|
||||
namespace ProposalSystem.Infrastructure.Services;
|
||||
|
||||
public class S3Service : IS3Service
|
||||
{
|
||||
private readonly IAmazonS3 _s3Client;
|
||||
|
||||
public S3Service(IAmazonS3 s3Client)
|
||||
{
|
||||
_s3Client = s3Client;
|
||||
}
|
||||
|
||||
public Task<string> GeneratePresignedUploadUrlAsync(string bucket, string key, string contentType, int expirationMinutes = 15)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
Verb = HttpVerb.PUT,
|
||||
Expires = DateTime.UtcNow.AddMinutes(expirationMinutes),
|
||||
ContentType = contentType,
|
||||
};
|
||||
|
||||
var url = _s3Client.GetPreSignedURL(request);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
|
||||
public Task<string> GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
Verb = HttpVerb.GET,
|
||||
Expires = DateTime.UtcNow.AddMinutes(expirationMinutes),
|
||||
};
|
||||
|
||||
var url = _s3Client.GetPreSignedURL(request);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using System.Text.Json;
|
||||
using Amazon.SecretsManager;
|
||||
using Amazon.SecretsManager.Model;
|
||||
|
||||
namespace ProposalSystem.Infrastructure.Services;
|
||||
|
||||
public static class SecretsManagerConnectionString
|
||||
{
|
||||
public static async Task<string> ResolveAsync(IAmazonSecretsManager client, string secretArn)
|
||||
{
|
||||
var response = await client.GetSecretValueAsync(new GetSecretValueRequest
|
||||
{
|
||||
SecretId = secretArn,
|
||||
});
|
||||
|
||||
var secret = JsonSerializer.Deserialize<DbSecret>(response.SecretString)
|
||||
?? throw new InvalidOperationException("Failed to deserialize DB secret");
|
||||
|
||||
return $"Host={secret.host};Port={secret.port};Database={secret.dbname};Username={secret.username};Password={secret.password};SSL Mode=Require;Trust Server Certificate=true";
|
||||
}
|
||||
|
||||
private record DbSecret(string host, int port, string dbname, string username, string password);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using System.Text.Json;
|
||||
using Amazon.SQS;
|
||||
using Amazon.SQS.Model;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
|
||||
namespace ProposalSystem.Infrastructure.Services;
|
||||
|
||||
public class SqsJobPublisher : IJobPublisher
|
||||
{
|
||||
private readonly IAmazonSQS _sqsClient;
|
||||
private readonly string _queueUrl;
|
||||
|
||||
public SqsJobPublisher(IAmazonSQS sqsClient, string queueUrl)
|
||||
{
|
||||
_sqsClient = sqsClient;
|
||||
_queueUrl = queueUrl;
|
||||
}
|
||||
|
||||
public async Task PublishAsync(string jobType, object payload, CancellationToken ct = default)
|
||||
{
|
||||
var message = new
|
||||
{
|
||||
jobType,
|
||||
payload,
|
||||
timestamp = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _sqsClient.SendMessageAsync(new SendMessageRequest
|
||||
{
|
||||
QueueUrl = _queueUrl,
|
||||
MessageBody = JsonSerializer.Serialize(message),
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue