mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 08:53:15 +00:00
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:
parent
b29dd0cc6b
commit
f9951d6dfa
18 changed files with 637 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
13
api/src/ProposalSystem.Api/Properties/launchSettings.json
Normal file
13
api/src/ProposalSystem.Api/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
32
api/src/ProposalSystem.Api/Services/DevServices.cs
Normal file
32
api/src/ProposalSystem.Api/Services/DevServices.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -8,4 +8,5 @@ public interface ICurrentUserService
|
|||
string Email { get; }
|
||||
UserRole Role { get; }
|
||||
string? IpAddress { get; }
|
||||
Task ResolveAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue