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:
Adam Moussa 2026-05-16 18:49:48 -04:00
parent 0b055b3ad9
commit d2e12d3940
38 changed files with 1802 additions and 21 deletions

View 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));
}
}

View file

@ -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);
}
}

View 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)));
}
}

View file

@ -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 });
}

View file

@ -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();
}
}

View 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();
}
}

View 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();
}
}

View file

@ -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,
}));
}
}

View 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();
}
}

View file

@ -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();

View file

@ -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>

View 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;
}
}

View 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
);

View 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
);

View file

@ -0,0 +1,8 @@
namespace ProposalSystem.Application.DTOs;
public record DashboardResponse(
int PendingCount,
int ApprovedThisWeek,
double AvgTurnaroundHours,
int TotalProposals
);

View 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
);

View 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
);

View 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
);

View 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
);

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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);
}

View file

@ -0,0 +1,6 @@
namespace ProposalSystem.Application.Interfaces;
public interface IJobPublisher
{
Task PublishAsync(string jobType, object payload, CancellationToken ct = default);
}

View file

@ -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);
}

View file

@ -0,0 +1,6 @@
namespace ProposalSystem.Application.Interfaces;
public interface IProposalNumberGenerator
{
Task<string> GenerateAsync(CancellationToken ct = default);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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>();
}
}

View file

@ -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
);
}

View file

@ -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}";
}
}

View file

@ -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
);
}

View 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);
}
}

View file

@ -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);
}

View file

@ -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);
}
}