Implement backend dev mode, internal API auth, and service layer enhancements

- Add dev-login endpoint with local JWT signing for local development
- Add InternalApiKeyMiddleware with timing-safe comparison for Lambda-to-API auth
- Add DevS3Service and NoOpJobPublisher for running without AWS services
- Implement CurrentUserService cascading user resolution (ID → sub → email → create)
- Add async ResolveAsync() to avoid synchronous DB calls in request pipeline
- Add /proposals/stats endpoint for efficient server-side status counts
- Guard status transitions: only allow Draft → InReview via update endpoint
- Add vendor proposals, generated PDFs, and similar proposals controllers
- Add ISimilarProposalService and SimilarProposalService
- Add [Authorize] to AddSimilarReference endpoint
This commit is contained in:
Adam Moussa 2026-05-16 22:10:07 -04:00
parent b29dd0cc6b
commit f9951d6dfa
18 changed files with 637 additions and 26 deletions

View file

@ -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<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request, CancellationToken ct)
{
var devMode = _config.GetValue<bool>("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<UserRole>(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<Claim>
{
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<CognitoTokenResponse?> 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

View file

@ -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<ActionResult<IReadOnlyList<VendorProposalResponse>>> 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<object>(v.ExtractedData)
)).ToList();
return Ok(vendors);
}
}

View file

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

View file

@ -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<ActionResult<IReadOnlyList<SimilarProposalResponse>>> 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<IActionResult> 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<ActionResult<ProposalStatsResponse>> GetStats(CancellationToken ct)
{
var stats = await _proposalService.GetStatsAsync(ct);
return Ok(stats);
}
[HttpPost("{id:guid}/similar-references")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<IActionResult> AddSimilarReference(
Guid id,
[FromBody] CreateSimilarReferenceRequest request,
CancellationToken ct)
{
await _similarService.AddReferenceAsync(id, request, ct);
return Created();
}
}

View file

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

View file

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

View file

@ -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<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
// Dev mode flag (read early for conditional setup)
var devMode = builder.Configuration.GetValue<bool>("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<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
}
// 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<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 =>
if (devMode)
builder.Services.AddScoped<IS3Service, DevS3Service>();
else
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
return new SqsJobPublisher(sqsClient, queueUrl);
});
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
}
else
{
builder.Services.AddScoped<IJobPublisher>(sp =>
{
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
return new SqsJobPublisher(sqsClient, jobsQueueUrl);
});
}
// HTTP client for Cognito token exchange
builder.Services.AddHttpClient();
@ -115,8 +166,18 @@ var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userService = context.RequestServices.GetRequiredService<ICurrentUserService>();
await userService.ResolveAsync();
}
await next();
});
app.MapControllers();
app.MapHealthChecks("/api/health");

View file

@ -0,0 +1,13 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

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

View file

@ -0,0 +1,32 @@
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Api.Services;
public class NoOpJobPublisher : IJobPublisher
{
private readonly ILogger<NoOpJobPublisher> _logger;
public NoOpJobPublisher(ILogger<NoOpJobPublisher> 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<string> 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<string> GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60)
{
return Task.FromResult($"http://localhost:5000/dev-null/download?bucket={bucket}&key={key}");
}
}

View file

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

View file

@ -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<T>(
int Page,
int PageSize
);
public record ProposalStatsResponse(
int TotalCount,
int InReviewCount,
int ApprovedCount,
int SentCount
);

View file

@ -0,0 +1,23 @@
namespace ProposalSystem.Application.DTOs;
public record SimilarProposalResponse(
string ProposalNumber,
string CustomerName,
string ServiceCategory,
float SimilarityScore,
decimal TotalBidAmount,
IReadOnlyList<SimilarLineItemResponse> LineItems
);
public record SimilarLineItemResponse(
string Description,
decimal Quantity,
string Unit,
decimal? UnitPrice,
decimal TotalPrice
);
public record CreateSimilarReferenceRequest(
string ReferencedLibraryItemId,
float SimilarityScore
);

View file

@ -8,4 +8,5 @@ public interface ICurrentUserService
string Email { get; }
UserRole Role { get; }
string? IpAddress { get; }
Task ResolveAsync();
}

View file

@ -13,4 +13,5 @@ public interface IProposalService
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);
Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default);
}

View file

@ -0,0 +1,9 @@
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Interfaces;
public interface ISimilarProposalService
{
Task<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync(Guid proposalId, CancellationToken ct = default);
Task AddReferenceAsync(Guid proposalId, CreateSimilarReferenceRequest request, CancellationToken ct = default);
}

View file

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

View file

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