diff --git a/api/src/ProposalSystem.Api/Controllers/AuthController.cs b/api/src/ProposalSystem.Api/Controllers/AuthController.cs index 73fc9fa..8bbf8cb 100644 --- a/api/src/ProposalSystem.Api/Controllers/AuthController.cs +++ b/api/src/ProposalSystem.Api/Controllers/AuthController.cs @@ -1,9 +1,11 @@ using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using System.Security.Claims; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using ProposalSystem.Domain.Entities; using ProposalSystem.Infrastructure.Data; @@ -86,6 +88,66 @@ public class AuthController : ControllerBase )); } + [HttpPost("dev-login")] + public async Task> DevLogin([FromBody] DevLoginRequest request, CancellationToken ct) + { + var devMode = _config.GetValue("Auth:DevMode"); + if (!devMode) + return NotFound(); + + var signingKey = _config["Auth:DevSigningKey"]; + if (string.IsNullOrEmpty(signingKey)) + return StatusCode(500, new { message = "Dev signing key not configured" }); + + var role = Enum.TryParse(request.Role, true, out var parsed) ? parsed : UserRole.Admin; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, ct); + if (user == null) + { + user = new User + { + Id = Guid.NewGuid(), + CognitoSub = $"dev-{Guid.NewGuid():N}", + Email = request.Email, + DisplayName = request.DisplayName ?? request.Email.Split('@')[0], + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.Users.Add(user); + await _db.SaveChangesAsync(ct); + } + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new("sub", user.CognitoSub), + new("email", user.Email), + new("name", user.DisplayName), + new("cognito:groups", role.ToString().ToLower() + "s"), + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: "proposal-system-dev", + audience: "proposal-system-dev", + claims: claims, + expires: DateTime.UtcNow.AddHours(12), + signingCredentials: creds); + + var tokenString = new JwtSecurityTokenHandler().WriteToken(token); + + return Ok(new AuthResponse( + user.Id.ToString(), + user.Email, + user.DisplayName, + user.Role.ToString(), + tokenString + )); + } + private async Task ExchangeCodeAsync( string domain, string clientId, string code, string redirectUri, CancellationToken ct) { @@ -110,6 +172,8 @@ public class AuthController : ControllerBase public record AuthCallbackRequest(string Code, string RedirectUri); +public record DevLoginRequest(string Email, string? DisplayName, string? Role); + public record AuthResponse(string Id, string Email, string DisplayName, string Role, string Token); internal class CognitoTokenResponse diff --git a/api/src/ProposalSystem.Api/Controllers/FilesController.cs b/api/src/ProposalSystem.Api/Controllers/FilesController.cs index 71a34b5..8026dd0 100644 --- a/api/src/ProposalSystem.Api/Controllers/FilesController.cs +++ b/api/src/ProposalSystem.Api/Controllers/FilesController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -111,4 +112,27 @@ public class FilesController : ControllerBase return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); } + + [HttpGet("vendors")] + [Authorize(Roles = "admins,sysadmins")] + public async Task>> GetVendors( + Guid proposalId, + CancellationToken ct) + { + var entities = await _db.VendorProposals + .Where(v => v.ProposalId == proposalId) + .OrderByDescending(v => v.UploadedAt) + .ToListAsync(ct); + + var vendors = entities.Select(v => new VendorProposalResponse( + v.Id, + v.VendorName, + v.FileName, + v.TotalVendorCost, + v.ProcessingStatus.ToString(), + string.IsNullOrEmpty(v.ExtractedData) ? null : JsonSerializer.Deserialize(v.ExtractedData) + )).ToList(); + + return Ok(vendors); + } } diff --git a/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs b/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs new file mode 100644 index 0000000..9677b6d --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/generated-pdfs")] +[Authorize] +public class GeneratedPdfsController : ControllerBase +{ + private readonly ProposalDbContext _db; + + public GeneratedPdfsController(ProposalDbContext db) + { + _db = db; + } + + [HttpPost] + public async Task Create([FromBody] CreateGeneratedPdfRequest request, CancellationToken ct) + { + var proposal = await _db.Proposals.FindAsync(new object[] { request.ProposalId }, ct); + if (proposal == null) return NotFound(); + + var pdf = new GeneratedPdf + { + Id = Guid.NewGuid(), + ProposalId = request.ProposalId, + Revision = proposal.CurrentRevision, + S3Key = request.S3Key, + GeneratedAt = DateTime.UtcNow, + GeneratedById = Guid.Empty, + }; + + _db.GeneratedPdfs.Add(pdf); + await _db.SaveChangesAsync(ct); + + return Created($"/api/generated-pdfs/{pdf.Id}", new { pdf.Id, pdf.S3Key, pdf.Revision }); + } +} + +public record CreateGeneratedPdfRequest(Guid ProposalId, string S3Key); diff --git a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs index cf0b455..899cbd9 100644 --- a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs +++ b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs @@ -12,11 +12,16 @@ public class ProposalsController : ControllerBase { private readonly IProposalService _proposalService; private readonly IJobPublisher _jobPublisher; + private readonly ISimilarProposalService _similarService; - public ProposalsController(IProposalService proposalService, IJobPublisher jobPublisher) + public ProposalsController( + IProposalService proposalService, + IJobPublisher jobPublisher, + ISimilarProposalService similarService) { _proposalService = proposalService; _jobPublisher = jobPublisher; + _similarService = similarService; } [HttpPost] @@ -95,6 +100,14 @@ public class ProposalsController : ControllerBase return Ok(result); } + [HttpGet("{id:guid}/similar")] + [Authorize(Roles = "admins,sysadmins")] + public async Task>> GetSimilar(Guid id, CancellationToken ct) + { + var result = await _similarService.GetSimilarProposalsAsync(id, ct); + return Ok(result); + } + [HttpPost("{id:guid}/generate-suggestions")] [Authorize(Roles = "admins,sysadmins")] public async Task GenerateSuggestions(Guid id, CancellationToken ct) @@ -110,4 +123,22 @@ public class ProposalsController : ControllerBase await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct); return Accepted(); } + + [HttpGet("stats")] + public async Task> GetStats(CancellationToken ct) + { + var stats = await _proposalService.GetStatsAsync(ct); + return Ok(stats); + } + + [HttpPost("{id:guid}/similar-references")] + [Authorize(Roles = "admins,sysadmins")] + public async Task AddSimilarReference( + Guid id, + [FromBody] CreateSimilarReferenceRequest request, + CancellationToken ct) + { + await _similarService.AddReferenceAsync(id, request, ct); + return Created(); + } } diff --git a/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs new file mode 100644 index 0000000..f311a99 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/vendor-proposals")] +[Authorize] +public class VendorProposalsController : ControllerBase +{ + private readonly ProposalDbContext _db; + + public VendorProposalsController(ProposalDbContext db) + { + _db = db; + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateVendorProposalRequest request, CancellationToken ct) + { + var vendor = await _db.VendorProposals.FindAsync(new object[] { id }, ct); + if (vendor == null) return NotFound(); + + if (request.VendorName != null) + vendor.VendorName = request.VendorName; + + if (request.ExtractedData != null) + vendor.ExtractedData = request.ExtractedData; + + if (request.TotalVendorCost.HasValue) + vendor.TotalVendorCost = request.TotalVendorCost.Value; + + if (request.ProcessingStatus != null && Enum.TryParse(request.ProcessingStatus, out var status)) + vendor.ProcessingStatus = status; + + await _db.SaveChangesAsync(ct); + + if (vendor.TotalVendorCost > 0) + { + var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct); + if (proposal != null) + { + proposal.VendorTotalCost = vendor.TotalVendorCost; + await _db.SaveChangesAsync(ct); + } + } + + return NoContent(); + } + + [HttpPut("{id:guid}/status")] + public async Task UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request, CancellationToken ct) + { + var vendor = await _db.VendorProposals.FindAsync(new object[] { id }, ct); + if (vendor == null) return NotFound(); + + if (Enum.TryParse(request.ProcessingStatus, out var status)) + vendor.ProcessingStatus = status; + + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} + +public record UpdateVendorProposalRequest( + string? VendorName, + string? ExtractedData, + decimal? TotalVendorCost, + string? ProcessingStatus +); + +public record UpdateStatusRequest(string ProcessingStatus); diff --git a/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs new file mode 100644 index 0000000..a88c3e7 --- /dev/null +++ b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace ProposalSystem.Api.Middleware; + +public class InternalApiKeyMiddleware +{ + private readonly RequestDelegate _next; + private readonly byte[] _apiKeyBytes; + + public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + var key = configuration["INTERNAL_API_KEY"] ?? ""; + _apiKeyBytes = Encoding.UTF8.GetBytes(key); + } + + public async Task InvokeAsync(HttpContext context) + { + if (_apiKeyBytes.Length > 0 && + context.Request.Headers.TryGetValue("X-Internal-Api-Key", out var providedKey) && + !string.IsNullOrEmpty(providedKey.ToString())) + { + var providedBytes = Encoding.UTF8.GetBytes(providedKey.ToString()); + if (CryptographicOperations.FixedTimeEquals(providedBytes, _apiKeyBytes)) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "system"), + new Claim(ClaimTypes.Role, "admins"), + new Claim("cognito:groups", "admins"), + }; + var identity = new ClaimsIdentity(claims, "InternalApiKey"); + context.User = new ClaimsPrincipal(identity); + } + } + + await _next(context); + } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index ef31749..b3ac383 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text; using Amazon.S3; using Amazon.SecretsManager; using Amazon.SQS; @@ -14,11 +15,17 @@ using ProposalSystem.Infrastructure.Services; var builder = WebApplication.CreateBuilder(args); -// AWS SDK clients -builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); -builder.Services.AddAWSService(); -builder.Services.AddAWSService(); -builder.Services.AddAWSService(); +// Dev mode flag (read early for conditional setup) +var devMode = builder.Configuration.GetValue("Auth:DevMode"); + +// AWS SDK clients (skip in dev mode — no real AWS credentials needed) +if (!devMode) +{ + builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); + builder.Services.AddAWSService(); + builder.Services.AddAWSService(); + builder.Services.AddAWSService(); +} // Database var dbSecretArn = builder.Configuration["DB_SECRET_ARN"]; @@ -35,8 +42,21 @@ else options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); } +// Internal API key (for Lambda-to-API calls) +var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"]; +if (!string.IsNullOrEmpty(internalApiKeySecretArn)) +{ + var smClient = new AmazonSecretsManagerClient(); + var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest + { + SecretId = internalApiKeySecretArn, + }).GetAwaiter().GetResult(); + builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString; +} + // Authentication var cognitoAuthority = builder.Configuration["Auth:Authority"]; + if (!string.IsNullOrEmpty(cognitoAuthority)) { builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -49,6 +69,26 @@ if (!string.IsNullOrEmpty(cognitoAuthority)) ValidateIssuer = true, ValidateAudience = false, ValidateLifetime = true, + RoleClaimType = "cognito:groups", + }; + }); +} +else if (devMode) +{ + var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!; + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)), + ValidateIssuer = true, + ValidIssuer = "proposal-system-dev", + ValidateAudience = true, + ValidAudience = "proposal-system-dev", + ValidateLifetime = true, + RoleClaimType = "cognito:groups", }; }); } @@ -68,13 +108,24 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(sp => +if (devMode) + builder.Services.AddScoped(); +else + builder.Services.AddScoped(); +builder.Services.AddScoped(); +var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; +if (string.IsNullOrEmpty(jobsQueueUrl)) { - var sqsClient = sp.GetRequiredService(); - var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; - return new SqsJobPublisher(sqsClient, queueUrl); -}); + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddScoped(sp => + { + var sqsClient = sp.GetRequiredService(); + return new SqsJobPublisher(sqsClient, jobsQueueUrl); + }); +} // HTTP client for Cognito token exchange builder.Services.AddHttpClient(); @@ -115,8 +166,18 @@ var app = builder.Build(); app.UseMiddleware(); app.UseCors(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); +app.Use(async (context, next) => +{ + if (context.User.Identity?.IsAuthenticated == true) + { + var userService = context.RequestServices.GetRequiredService(); + await userService.ResolveAsync(); + } + await next(); +}); app.MapControllers(); app.MapHealthChecks("/api/health"); diff --git a/api/src/ProposalSystem.Api/Properties/launchSettings.json b/api/src/ProposalSystem.Api/Properties/launchSettings.json new file mode 100644 index 0000000..df85fca --- /dev/null +++ b/api/src/ProposalSystem.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/api/src/ProposalSystem.Api/Services/CurrentUserService.cs b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs index 3d1e752..2da3c9d 100644 --- a/api/src/ProposalSystem.Api/Services/CurrentUserService.cs +++ b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs @@ -25,28 +25,46 @@ public class CurrentUserService : ICurrentUserService public string? IpAddress => _httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString(); - private User GetUser() + public async Task ResolveAsync() { - if (_cachedUser != null) return _cachedUser; + if (_cachedUser != null) return; - var cognitoSub = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? _httpContext.HttpContext?.User.FindFirstValue("sub") + var principal = _httpContext.HttpContext?.User ?? throw new UnauthorizedAccessException("No authenticated user"); - _cachedUser = _db.Users - .FirstOrDefault(u => u.CognitoSub == cognitoSub); + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var sub = principal.FindFirstValue("sub"); + + if (userId != null && Guid.TryParse(userId, out var parsedId)) + { + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == parsedId); + } + + if (_cachedUser == null && sub != null) + { + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.CognitoSub == sub); + } if (_cachedUser == null) { - var email = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.Email) - ?? _httpContext.HttpContext?.User.FindFirstValue("email") + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") ?? "unknown@seahaven.com"; - var name = _httpContext.HttpContext?.User.FindFirstValue("name") + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + var name = principal.FindFirstValue("name") ?? email.Split('@')[0]; - var groups = _httpContext.HttpContext?.User.FindAll("cognito:groups") - .Select(c => c.Value).ToList() ?? new List(); + var groups = principal.FindAll("cognito:groups") + .Select(c => c.Value).ToList(); var role = groups.Contains("sysadmins") ? UserRole.SysAdmin : groups.Contains("admins") ? UserRole.Admin @@ -55,7 +73,69 @@ public class CurrentUserService : ICurrentUserService _cachedUser = new User { Id = Guid.NewGuid(), - CognitoSub = cognitoSub, + CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}", + Email = email, + DisplayName = name, + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + _db.Users.Add(_cachedUser); + await _db.SaveChangesAsync(); + } + } + + private User GetUser() + { + if (_cachedUser != null) return _cachedUser; + + var principal = _httpContext.HttpContext?.User + ?? throw new UnauthorizedAccessException("No authenticated user"); + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var sub = principal.FindFirstValue("sub"); + + if (userId != null && Guid.TryParse(userId, out var parsedId)) + { + _cachedUser = _db.Users.FirstOrDefault(u => u.Id == parsedId); + } + + if (_cachedUser == null && sub != null) + { + _cachedUser = _db.Users.FirstOrDefault(u => u.CognitoSub == sub); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + _cachedUser = _db.Users.FirstOrDefault(u => u.Email == email); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + var name = principal.FindFirstValue("name") + ?? email.Split('@')[0]; + + var groups = principal.FindAll("cognito:groups") + .Select(c => c.Value).ToList(); + + var role = groups.Contains("sysadmins") ? UserRole.SysAdmin + : groups.Contains("admins") ? UserRole.Admin + : UserRole.Dispatcher; + + _cachedUser = new User + { + Id = Guid.NewGuid(), + CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}", Email = email, DisplayName = name, Role = role, diff --git a/api/src/ProposalSystem.Api/Services/DevServices.cs b/api/src/ProposalSystem.Api/Services/DevServices.cs new file mode 100644 index 0000000..e366062 --- /dev/null +++ b/api/src/ProposalSystem.Api/Services/DevServices.cs @@ -0,0 +1,32 @@ +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Services; + +public class NoOpJobPublisher : IJobPublisher +{ + private readonly ILogger _logger; + + public NoOpJobPublisher(ILogger logger) + { + _logger = logger; + } + + public Task PublishAsync(string jobType, object payload, CancellationToken ct = default) + { + _logger.LogInformation("Dev mode - skipping job publish: {JobType}", jobType); + return Task.CompletedTask; + } +} + +public class DevS3Service : IS3Service +{ + public Task GeneratePresignedUploadUrlAsync(string bucket, string key, string contentType, int expirationMinutes = 15) + { + return Task.FromResult($"http://localhost:5000/dev-null/upload?bucket={bucket}&key={key}"); + } + + public Task GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60) + { + return Task.FromResult($"http://localhost:5000/dev-null/download?bucket={bucket}&key={key}"); + } +} diff --git a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs index c0dfa7a..ea7e393 100644 --- a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs @@ -10,3 +10,12 @@ public record PdfDownloadResponse( string DownloadUrl, DateTime ExpiresAt ); + +public record VendorProposalResponse( + Guid Id, + string VendorName, + string FileName, + decimal TotalVendorCost, + string ProcessingStatus, + object? ExtractedData +); diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs index f1d0a20..27f1aa6 100644 --- a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -15,7 +15,8 @@ public record CreateProposalRequest( public record UpdateProposalRequest( string? RefinedScope, string? Notes, - Guid? AssignedAdminId + Guid? AssignedAdminId, + ProposalStatus? Status ); public record ProposalResponse( @@ -76,3 +77,10 @@ public record PagedResponse( int Page, int PageSize ); + +public record ProposalStatsResponse( + int TotalCount, + int InReviewCount, + int ApprovedCount, + int SentCount +); diff --git a/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs new file mode 100644 index 0000000..49f1e42 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs @@ -0,0 +1,23 @@ +namespace ProposalSystem.Application.DTOs; + +public record SimilarProposalResponse( + string ProposalNumber, + string CustomerName, + string ServiceCategory, + float SimilarityScore, + decimal TotalBidAmount, + IReadOnlyList LineItems +); + +public record SimilarLineItemResponse( + string Description, + decimal Quantity, + string Unit, + decimal? UnitPrice, + decimal TotalPrice +); + +public record CreateSimilarReferenceRequest( + string ReferencedLibraryItemId, + float SimilarityScore +); diff --git a/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs index d71827d..df78444 100644 --- a/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs @@ -8,4 +8,5 @@ public interface ICurrentUserService string Email { get; } UserRole Role { get; } string? IpAddress { get; } + Task ResolveAsync(); } diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs index 411ef18..47f9b00 100644 --- a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs @@ -13,4 +13,5 @@ public interface IProposalService Task ReviseAsync(Guid id, CancellationToken ct = default); Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); Task> GetAuditTrailAsync(Guid id, CancellationToken ct = default); + Task GetStatsAsync(CancellationToken ct = default); } diff --git a/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs new file mode 100644 index 0000000..79d9737 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs @@ -0,0 +1,9 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface ISimilarProposalService +{ + Task> GetSimilarProposalsAsync(Guid proposalId, CancellationToken ct = default); + Task AddReferenceAsync(Guid proposalId, CreateSimilarReferenceRequest request, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index 2679f7e..19e19bd 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -56,7 +56,7 @@ public class ProposalService : IProposalService await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct); - await _jobPublisher.PublishAsync("pdf-extract", new { proposalId = proposal.Id }, ct); + await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); return MapToResponse(proposal); } @@ -147,6 +147,10 @@ public class ProposalService : IProposalService if (request.AssignedAdminId.HasValue) proposal.AssignedAdminId = request.AssignedAdminId.Value; + if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft + && request.Status.Value == ProposalStatus.InReview) + proposal.Status = request.Status.Value; + proposal.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); @@ -296,6 +300,19 @@ public class ProposalService : IProposalService .ToListAsync(ct); } + public async Task GetStatsAsync(CancellationToken ct = default) + { + var userId = _currentUser.UserId; + var baseQuery = _db.Proposals.Where(p => p.SubmittedById == userId); + + var total = await baseQuery.CountAsync(ct); + var inReview = await baseQuery.CountAsync(p => p.Status == ProposalStatus.InReview, ct); + var approved = await baseQuery.CountAsync(p => p.Status == ProposalStatus.Approved, ct); + var sent = await baseQuery.CountAsync(p => p.Status == ProposalStatus.Sent, ct); + + return new ProposalStatsResponse(total, inReview, approved, sent); + } + private static ProposalResponse MapToResponse(Proposal p) => new( p.Id, p.ProposalNumber, diff --git a/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs new file mode 100644 index 0000000..71b8a9e --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs @@ -0,0 +1,78 @@ +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 SimilarProposalService : ISimilarProposalService +{ + private readonly ProposalDbContext _db; + + public SimilarProposalService(ProposalDbContext db) + { + _db = db; + } + + public async Task> GetSimilarProposalsAsync( + Guid proposalId, + CancellationToken ct = default) + { + var references = await _db.SimilarProposalReferences + .Where(r => r.ProposalId == proposalId) + .OrderByDescending(r => r.SimilarityScore) + .Take(5) + .ToListAsync(ct); + + var results = new List(); + + foreach (var reference in references) + { + var referencedProposal = await _db.Proposals + .Include(p => p.LineItems) + .FirstOrDefaultAsync(p => p.ProposalNumber == reference.ReferencedLibraryItemId, ct); + + if (referencedProposal == null) continue; + + results.Add(new SimilarProposalResponse( + referencedProposal.ProposalNumber, + referencedProposal.CustomerName, + referencedProposal.ServiceCategory.ToString(), + reference.SimilarityScore, + referencedProposal.TotalBidAmount, + referencedProposal.LineItems + .OrderBy(li => li.SortOrder) + .Select(li => new SimilarLineItemResponse( + li.Description, + li.Quantity, + li.Unit, + li.UnitPrice, + li.TotalPrice + )) + .ToList() + )); + } + + return results; + } + + public async Task AddReferenceAsync( + Guid proposalId, + CreateSimilarReferenceRequest request, + CancellationToken ct = default) + { + var reference = new SimilarProposalReference + { + Id = Guid.NewGuid(), + ProposalId = proposalId, + ReferencedLibraryItemId = request.ReferencedLibraryItemId, + SimilarityScore = request.SimilarityScore, + ReferencedAt = DateTime.UtcNow, + ReferencedById = Guid.Empty, + }; + + _db.SimilarProposalReferences.Add(reference); + await _db.SaveChangesAsync(ct); + } +}