proposal-system/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs
Adam Moussa 8d73e66a17 fix(api): API-M2, M5, M7, M9, M10, M12, M13 — Medium audit findings
- API-M2: Add comment for fail-loud auth config guard (already implemented)
- API-M5: Add FluentValidation validators for VendorProposal, GeneratedPdf,
  and SimilarReference DTOs; move request records to Application DTOs
- API-M7: Add AsNoTracking() to all read-only queries in ProposalService,
  LineItemService, AdminController, UsersController, FilesController
- API-M9: Log stderr from dev PDF generation instead of returning to client
- API-M10: Return generic "Authentication service unavailable" in auth
  callbacks instead of leaking Cognito/DevMode configuration state
- API-M12: Enrich audit logging with before/after values for status changes,
  proposal edits, and line item operations using structured JSON
- API-M13: Log previous role alongside new role on user role changes in
  both UsersController and Cognito-synced role updates in AuthController
2026-05-27 18:18:44 -04:00

515 lines
20 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Infrastructure.Services;
// Fix: API-H6 — add structured logging for state transitions and errors
public class ProposalService : IProposalService
{
private readonly ProposalDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IProposalNumberGenerator _numberGenerator;
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly ILogger<ProposalService> _logger;
public ProposalService(
ProposalDbContext db,
ICurrentUserService currentUser,
IProposalNumberGenerator numberGenerator,
IAuditService audit,
IJobPublisher jobPublisher,
ILogger<ProposalService> logger)
{
_db = db;
_currentUser = currentUser;
_numberGenerator = numberGenerator;
_audit = audit;
_jobPublisher = jobPublisher;
_logger = logger;
}
public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var proposalNumber = await _numberGenerator.GenerateAsync(ct);
var now = DateTime.UtcNow;
var proposal = new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = proposalNumber,
WorkOrderNumber = request.WorkOrderNumber,
PoNumber = request.PoNumber,
CustomerName = request.CustomerName,
CustomerAddress = request.CustomerAddress,
ScopeOfWork = request.ScopeOfWork,
ServiceCategory = request.ServiceCategory,
Priority = request.Priority,
Status = ProposalStatus.InReview,
Notes = request.Notes ?? string.Empty,
SubmittedById = _currentUser.UserId,
SubmittedAt = now,
CreatedAt = now,
UpdatedAt = now,
};
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("Proposal {ProposalId} created with number {ProposalNumber} by user {UserId}",
proposal.Id, proposalNumber, _currentUser.UserId);
try
{
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write audit log for proposal submission {ProposalId}", proposal.Id);
}
try
{
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish suggestions job for proposal {ProposalId}", proposal.Id);
}
return MapToResponse(proposal);
}
public async Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only query
var proposal = await _db.Proposals
.AsNoTracking()
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct);
if (proposal == null) return null;
if (_currentUser.Role == UserRole.Dispatcher && proposal.SubmittedById != _currentUser.UserId)
return null;
return MapToResponse(proposal);
}
public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default)
{
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
// Fix: API-M7 — AsNoTracking on read-only list query
var query = _db.Proposals
.AsNoTracking()
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.AsQueryable();
if (_currentUser.Role == UserRole.Dispatcher || filter.Mine)
{
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((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProposalListResponse(
p.Id,
p.ProposalNumber,
p.CustomerName,
p.WorkOrderNumber,
p.ServiceCategory,
p.Priority,
p.Status,
p.TotalBidAmount,
p.SubmittedAt,
p.SubmittedBy != null ? p.SubmittedBy.DisplayName : null,
p.AssignedAdmin != null ? p.AssignedAdmin.DisplayName : null
))
.ToListAsync(ct);
return new PagedResponse<ProposalListResponse>(items, totalCount, page, pageSize);
}
public async Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, 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)
?? throw new KeyNotFoundException($"Proposal {id} not found");
// Fix: API-M12 — capture before/after values for audit trail
var changes = new Dictionary<string, object>();
if (request.RefinedScope != null)
{
changes["refinedScope"] = new { old = proposal.RefinedScope, @new = request.RefinedScope };
proposal.RefinedScope = request.RefinedScope;
}
if (request.Notes != null)
{
changes["notes"] = new { old = proposal.Notes, @new = request.Notes };
proposal.Notes = request.Notes;
}
if (request.PoNumber != null)
{
changes["poNumber"] = new { old = proposal.PoNumber, @new = request.PoNumber };
proposal.PoNumber = request.PoNumber;
}
if (request.WorkOrderNumber != null)
{
changes["workOrderNumber"] = new { old = proposal.WorkOrderNumber, @new = request.WorkOrderNumber };
proposal.WorkOrderNumber = request.WorkOrderNumber;
}
if (request.AssignedAdminId.HasValue)
{
changes["assignedAdminId"] = new { old = proposal.AssignedAdminId, @new = request.AssignedAdminId.Value };
proposal.AssignedAdminId = request.AssignedAdminId.Value;
}
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var auditDetails = changes.Count > 0 ? JsonSerializer.Serialize(changes) : null;
await _audit.LogAsync(AuditAction.Edit, id, auditDetails, ct);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.LineItems)
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Approved)
{
_logger.LogInformation("Proposal {ProposalId} already approved, returning idempotent response", id);
return MapToResponse(proposal);
}
if (proposal.Status != ProposalStatus.InReview && proposal.Status != ProposalStatus.Revised)
{
_logger.LogWarning("Invalid state transition: cannot approve proposal {ProposalId} in status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only proposals in review can be approved");
}
if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0))
{
_logger.LogWarning("Cannot approve proposal {ProposalId}: no priced line items", id);
throw new InvalidOperationException("Cannot approve proposal without priced line items");
}
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
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);
var approveAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Approved.ToString() } });
await _audit.LogAsync(AuditAction.Approve, id, approveAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} approved by user {UserId}, total bid {TotalBidAmount}",
id, _currentUser.UserId, proposal.TotalBidAmount);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.InReview)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be returned to review");
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.InReview;
proposal.ApprovedById = null;
proposal.ApprovedAt = null;
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var returnAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.InReview.ToString() } });
await _audit.LogAsync(AuditAction.ReturnToReview, id, returnAuditDetails, ct);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Sent)
{
_logger.LogInformation("Proposal {ProposalId} already sent, returning idempotent response", id);
return MapToResponse(proposal);
}
if (proposal.Status != ProposalStatus.Approved)
{
_logger.LogWarning("Invalid state transition: cannot mark proposal {ProposalId} as sent from status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only approved proposals can be marked as sent");
}
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.Sent;
proposal.SentAt = DateTime.UtcNow;
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var sentAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Sent.ToString() } });
await _audit.LogAsync(AuditAction.MarkSent, id, sentAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId);
await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.LineItems)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Revised)
{
var existingRevision = await _db.Proposals
.FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct);
if (existingRevision != null)
{
_logger.LogInformation("Proposal {ProposalId} already revised, returning existing revision {RevisionId}",
id, existingRevision.Id);
return MapToResponse(existingRevision);
}
}
if (proposal.Status != ProposalStatus.Sent)
{
_logger.LogWarning("Invalid state transition: cannot revise proposal {ProposalId} in status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only sent proposals can be revised");
}
var revision = new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}",
WorkOrderNumber = proposal.WorkOrderNumber,
PoNumber = proposal.PoNumber,
CustomerName = proposal.CustomerName,
CustomerAddress = proposal.CustomerAddress,
ScopeOfWork = proposal.ScopeOfWork,
RefinedScope = proposal.RefinedScope,
ServiceCategory = proposal.ServiceCategory,
Priority = proposal.Priority,
Status = ProposalStatus.InReview,
Notes = proposal.Notes,
TotalBidAmount = proposal.TotalBidAmount,
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,
});
}
// Fix: API-M12 — capture before/after status for audit trail
var previousReviseStatus = proposal.Status;
proposal.Status = ProposalStatus.Revised;
proposal.UpdatedAt = DateTime.UtcNow;
_db.Proposals.Add(revision);
await _db.SaveChangesAsync(ct);
var reviseAuditDetails = JsonSerializer.Serialize(new
{
status = new { old = previousReviseStatus.ToString(), @new = ProposalStatus.Revised.ToString() },
message = $"Revised from {proposal.Id}"
});
await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, reviseAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} revised to {RevisionId} (revision {RevisionNumber}) by user {UserId}",
id, revision.Id, revision.CurrentRevision, _currentUser.UserId);
return MapToResponse(revision);
}
public async Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
var rootId = proposal.ParentProposalId ?? proposal.Id;
// Fix: API-M7 — AsNoTracking on read-only revision history query
var revisions = await _db.Proposals
.AsNoTracking()
.Where(p => p.Id == rootId || p.ParentProposalId == rootId)
.OrderBy(p => p.CurrentRevision)
.ToListAsync(ct);
return revisions.Select(MapToResponse).ToList();
}
public async Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only audit trail query
return await _db.AuditLogs
.AsNoTracking()
.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);
}
public async Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only stats query
var query = _db.Proposals.AsNoTracking().AsQueryable();
if (_currentUser.Role == UserRole.Dispatcher)
query = query.Where(p => p.SubmittedById == _currentUser.UserId);
var counts = await query
.GroupBy(_ => 1)
.Select(g => new
{
Total = g.Count(),
InReview = g.Count(p => p.Status == ProposalStatus.InReview),
Approved = g.Count(p => p.Status == ProposalStatus.Approved),
Sent = g.Count(p => p.Status == ProposalStatus.Sent),
})
.FirstOrDefaultAsync(ct);
return counts == null
? new ProposalStatsResponse(0, 0, 0, 0)
: new ProposalStatsResponse(counts.Total, counts.InReview, counts.Approved, counts.Sent);
}
private static ProposalResponse MapToResponse(Proposal p) => new(
p.Id,
p.ProposalNumber,
p.WorkOrderNumber,
p.PoNumber,
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
);
}