diff --git a/api/src/ProposalSystem.Api/Controllers/AdminController.cs b/api/src/ProposalSystem.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..5cb7e0a --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/AdminController.cs @@ -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> 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)); + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/CustomersController.cs b/api/src/ProposalSystem.Api/Controllers/CustomersController.cs new file mode 100644 index 0000000..c4df356 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/CustomersController.cs @@ -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>> Search( + [FromQuery] string? query, + CancellationToken ct) + { + var result = await _customerService.SearchAsync(query, ct); + return Ok(result); + } + + [HttpPost] + public async Task> Create( + [FromBody] CreateCustomerRequest request, + CancellationToken ct) + { + var result = await _customerService.CreateAsync(request, ct); + return Created($"/api/customers/{result.Id}", result); + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/FilesController.cs b/api/src/ProposalSystem.Api/Controllers/FilesController.cs new file mode 100644 index 0000000..71a34b5 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/FilesController.cs @@ -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> 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> 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> 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))); + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/HealthController.cs b/api/src/ProposalSystem.Api/Controllers/HealthController.cs deleted file mode 100644 index e631afc..0000000 --- a/api/src/ProposalSystem.Api/Controllers/HealthController.cs +++ /dev/null @@ -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 }); -} diff --git a/api/src/ProposalSystem.Api/Controllers/LineItemsController.cs b/api/src/ProposalSystem.Api/Controllers/LineItemsController.cs new file mode 100644 index 0000000..d9fc760 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/LineItemsController.cs @@ -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>> GetAll( + Guid proposalId, + CancellationToken ct) + { + var result = await _lineItemService.GetByProposalIdAsync(proposalId, ct); + return Ok(result); + } + + [HttpPost] + [Authorize(Roles = "admins,sysadmins")] + public async Task> 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>> 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 Delete( + Guid proposalId, + Guid itemId, + CancellationToken ct) + { + await _lineItemService.DeleteAsync(proposalId, itemId, ct); + return NoContent(); + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs new file mode 100644 index 0000000..cf0b455 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs @@ -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> 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>> GetAll( + [FromQuery] ProposalFilterRequest filter, + CancellationToken ct) + { + var result = await _proposalService.GetAllAsync(filter, ct); + return Ok(result); + } + + [HttpGet("{id:guid}")] + public async Task> 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> 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> 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> 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> Revise(Guid id, CancellationToken ct) + { + var result = await _proposalService.ReviseAsync(id, ct); + return Ok(result); + } + + [HttpGet("{id:guid}/history")] + public async Task>> 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>> 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 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 Regenerate(Guid id, CancellationToken ct) + { + await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct); + return Accepted(); + } +} diff --git a/api/src/ProposalSystem.Api/Controllers/UsersController.cs b/api/src/ProposalSystem.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..042f092 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/UsersController.cs @@ -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> 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>> 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 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(); + } +} diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs new file mode 100644 index 0000000..fb848ad --- /dev/null +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -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 _logger; + + public GlobalExceptionHandler(ILogger 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, + })); + } +} diff --git a/api/src/ProposalSystem.Api/Middleware/ValidationFilter.cs b/api/src/ProposalSystem.Api/Middleware/ValidationFilter.cs new file mode 100644 index 0000000..902658d --- /dev/null +++ b/api/src/ProposalSystem.Api/Middleware/ValidationFilter.cs @@ -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(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(); + } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index a6f7942..02ffc5c 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -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(); +builder.Services.AddAWSService(); +builder.Services.AddAWSService(); -builder.Services.AddDbContext(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(options => + options.UseNpgsql(connectionString)); +} +else +{ + builder.Services.AddDbContext(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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => +{ + var sqsClient = sp.GetRequiredService(); + var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; + return new SqsJobPublisher(sqsClient, queueUrl); +}); + +// Validation +builder.Services.AddValidatorsFromAssemblyContaining(); + +// Controllers +builder.Services.AddControllers(options => +{ + options.Filters.Add(); +}); + +// Middleware +builder.Services.AddTransient(); + +// Health checks builder.Services.AddHealthChecks() .AddDbContextCheck(); +// 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(); +app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj index 17e9175..257f631 100644 --- a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj +++ b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj @@ -17,8 +17,11 @@ + + + diff --git a/api/src/ProposalSystem.Api/Services/CurrentUserService.cs b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs new file mode 100644 index 0000000..3d1e752 --- /dev/null +++ b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs @@ -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(); + + 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; + } +} diff --git a/api/src/ProposalSystem.Application/DTOs/AuditDtos.cs b/api/src/ProposalSystem.Application/DTOs/AuditDtos.cs new file mode 100644 index 0000000..4414015 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/AuditDtos.cs @@ -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 +); diff --git a/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs b/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs new file mode 100644 index 0000000..3d8a994 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/CustomerDtos.cs @@ -0,0 +1,13 @@ +namespace ProposalSystem.Application.DTOs; + +public record CustomerResponse( + Guid Id, + string Name, + List Addresses, + DateTime CreatedAt +); + +public record CreateCustomerRequest( + string Name, + List? Addresses +); diff --git a/api/src/ProposalSystem.Application/DTOs/DashboardDtos.cs b/api/src/ProposalSystem.Application/DTOs/DashboardDtos.cs new file mode 100644 index 0000000..446f741 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/DashboardDtos.cs @@ -0,0 +1,8 @@ +namespace ProposalSystem.Application.DTOs; + +public record DashboardResponse( + int PendingCount, + int ApprovedThisWeek, + double AvgTurnaroundHours, + int TotalProposals +); diff --git a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs new file mode 100644 index 0000000..c0dfa7a --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs @@ -0,0 +1,12 @@ +namespace ProposalSystem.Application.DTOs; + +public record PresignedUploadResponse( + string UploadUrl, + string S3Key, + DateTime ExpiresAt +); + +public record PdfDownloadResponse( + string DownloadUrl, + DateTime ExpiresAt +); diff --git a/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs b/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs new file mode 100644 index 0000000..33fd798 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs @@ -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 LineItems +); + +public record UpdateLineItemEntry( + Guid? Id, + string Description, + decimal Quantity, + string Unit, + decimal? UnitPrice, + decimal TotalPrice, + PricingMode PricingMode, + int SortOrder, + LineItemSource Source +); diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs new file mode 100644 index 0000000..f1d0a20 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -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( + IReadOnlyList Items, + int TotalCount, + int Page, + int PageSize +); diff --git a/api/src/ProposalSystem.Application/DTOs/UserDtos.cs b/api/src/ProposalSystem.Application/DTOs/UserDtos.cs new file mode 100644 index 0000000..08f4506 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/UserDtos.cs @@ -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 +); diff --git a/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs b/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs new file mode 100644 index 0000000..8551944 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs @@ -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); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..d71827d --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs @@ -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; } +} diff --git a/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs b/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs new file mode 100644 index 0000000..6a22856 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ICustomerService.cs @@ -0,0 +1,9 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface ICustomerService +{ + Task> SearchAsync(string? query, CancellationToken ct = default); + Task CreateAsync(CreateCustomerRequest request, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IJobPublisher.cs b/api/src/ProposalSystem.Application/Interfaces/IJobPublisher.cs new file mode 100644 index 0000000..9d03837 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IJobPublisher.cs @@ -0,0 +1,6 @@ +namespace ProposalSystem.Application.Interfaces; + +public interface IJobPublisher +{ + Task PublishAsync(string jobType, object payload, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/ILineItemService.cs b/api/src/ProposalSystem.Application/Interfaces/ILineItemService.cs new file mode 100644 index 0000000..b3dd8c6 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ILineItemService.cs @@ -0,0 +1,11 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface ILineItemService +{ + Task> GetByProposalIdAsync(Guid proposalId, CancellationToken ct = default); + Task CreateAsync(Guid proposalId, CreateLineItemRequest request, CancellationToken ct = default); + Task> BulkUpdateAsync(Guid proposalId, BulkUpdateLineItemsRequest request, CancellationToken ct = default); + Task DeleteAsync(Guid proposalId, Guid lineItemId, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalNumberGenerator.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalNumberGenerator.cs new file mode 100644 index 0000000..25020aa --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalNumberGenerator.cs @@ -0,0 +1,6 @@ +namespace ProposalSystem.Application.Interfaces; + +public interface IProposalNumberGenerator +{ + Task GenerateAsync(CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs new file mode 100644 index 0000000..411ef18 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs @@ -0,0 +1,16 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface IProposalService +{ + Task CreateAsync(CreateProposalRequest request, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default); + Task UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default); + Task ApproveAsync(Guid id, CancellationToken ct = default); + Task MarkSentAsync(Guid id, CancellationToken ct = default); + Task ReviseAsync(Guid id, CancellationToken ct = default); + Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); + Task> GetAuditTrailAsync(Guid id, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Application/Interfaces/IS3Service.cs b/api/src/ProposalSystem.Application/Interfaces/IS3Service.cs new file mode 100644 index 0000000..d38b3fb --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/IS3Service.cs @@ -0,0 +1,7 @@ +namespace ProposalSystem.Application.Interfaces; + +public interface IS3Service +{ + Task GeneratePresignedUploadUrlAsync(string bucket, string key, string contentType, int expirationMinutes = 15); + Task GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60); +} diff --git a/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs b/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs new file mode 100644 index 0000000..959110f --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/CreateCustomerValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +public class CreateCustomerValidator : AbstractValidator +{ + public CreateCustomerValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Customer name is required") + .MaximumLength(200); + } +} diff --git a/api/src/ProposalSystem.Application/Validators/CreateLineItemValidator.cs b/api/src/ProposalSystem.Application/Validators/CreateLineItemValidator.cs new file mode 100644 index 0000000..40274ed --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/CreateLineItemValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +public class CreateLineItemValidator : AbstractValidator +{ + 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); + } +} diff --git a/api/src/ProposalSystem.Application/Validators/CreateProposalValidator.cs b/api/src/ProposalSystem.Application/Validators/CreateProposalValidator.cs new file mode 100644 index 0000000..afb3411 --- /dev/null +++ b/api/src/ProposalSystem.Application/Validators/CreateProposalValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Validators; + +public class CreateProposalValidator : AbstractValidator +{ + 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); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs b/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs new file mode 100644 index 0000000..9909d9a --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs @@ -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); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs new file mode 100644 index 0000000..a0edf84 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs @@ -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> 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 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(), + customer.CreatedAt + ); + } + + private static List DeserializeAddresses(string? json) + { + if (string.IsNullOrEmpty(json)) return new List(); + return JsonSerializer.Deserialize>(json) ?? new List(); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs new file mode 100644 index 0000000..cc2e5bf --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs @@ -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> 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 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> 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 + ); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalNumberGenerator.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalNumberGenerator.cs new file mode 100644 index 0000000..2b14af2 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalNumberGenerator.cs @@ -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 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}"; + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs new file mode 100644 index 0000000..2679f7e --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -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 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 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> 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(items, totalCount, filter.Page, filter.PageSize); + } + + public async Task 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 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 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 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> 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> 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 + ); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/S3Service.cs b/api/src/ProposalSystem.Infrastructure/Services/S3Service.cs new file mode 100644 index 0000000..483cebb --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/S3Service.cs @@ -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 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 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); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/SecretsManagerConnectionString.cs b/api/src/ProposalSystem.Infrastructure/Services/SecretsManagerConnectionString.cs new file mode 100644 index 0000000..ee6a147 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SecretsManagerConnectionString.cs @@ -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 ResolveAsync(IAmazonSecretsManager client, string secretArn) + { + var response = await client.GetSecretValueAsync(new GetSecretValueRequest + { + SecretId = secretArn, + }); + + var secret = JsonSerializer.Deserialize(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); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/SqsJobPublisher.cs b/api/src/ProposalSystem.Infrastructure/Services/SqsJobPublisher.cs new file mode 100644 index 0000000..0bf7151 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SqsJobPublisher.cs @@ -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); + } +}