proposal-system/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs
Adam Moussa d2e12d3940 Implement Backend API Core (Phase 1)
Complete API layer with Clean Architecture:
- Application DTOs (proposals, line items, customers, users, files, audit, dashboard)
- Service interfaces (IProposalService, ILineItemService, ICustomerService, IAuditService, IS3Service, IJobPublisher)
- FluentValidation validators for all create requests
- Infrastructure service implementations (ProposalService, LineItemService, CustomerService, AuditService, S3Service, SqsJobPublisher, ProposalNumberGenerator)
- Secrets Manager connection string resolver for RDS
- API controllers: Proposals (CRUD + approve/send/revise), LineItems (CRUD + bulk), Customers, Users, Files (presigned upload/download), Admin (dashboard)
- Middleware: GlobalExceptionHandler (ProblemDetails), ValidationFilter (FluentValidation pipeline)
- CurrentUserService (Cognito JWT claims -> User entity, auto-provisioning)
- Full DI configuration in Program.cs with Lambda hosting
- Role-based authorization (dispatchers, admins, sysadmins)
- CORS configured for proposals.seahaven.com + localhost
2026-05-16 18:49:48 -04:00

135 lines
4.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Infrastructure.Services;
public class LineItemService : ILineItemService
{
private readonly ProposalDbContext _db;
private readonly IAuditService _audit;
public LineItemService(ProposalDbContext db, IAuditService audit)
{
_db = db;
_audit = audit;
}
public async Task<IReadOnlyList<LineItemResponse>> GetByProposalIdAsync(Guid proposalId, CancellationToken ct = default)
{
return await _db.LineItems
.Where(li => li.ProposalId == proposalId)
.OrderBy(li => li.SortOrder)
.Select(li => MapToResponse(li))
.ToListAsync(ct);
}
public async Task<LineItemResponse> CreateAsync(Guid proposalId, CreateLineItemRequest request, CancellationToken ct = default)
{
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
var now = DateTime.UtcNow;
var lineItem = new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposalId,
Description = request.Description,
Quantity = request.Quantity,
Unit = request.Unit,
UnitPrice = request.UnitPrice,
TotalPrice = request.TotalPrice,
PricingMode = request.PricingMode,
SortOrder = request.SortOrder,
Source = request.Source,
CreatedAt = now,
UpdatedAt = now,
};
_db.LineItems.Add(lineItem);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
return MapToResponse(lineItem);
}
public async Task<IReadOnlyList<LineItemResponse>> BulkUpdateAsync(Guid proposalId, BulkUpdateLineItemsRequest request, CancellationToken ct = default)
{
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
var existing = await _db.LineItems
.Where(li => li.ProposalId == proposalId)
.ToListAsync(ct);
_db.LineItems.RemoveRange(existing);
var now = DateTime.UtcNow;
var newItems = request.LineItems.Select(entry => new LineItem
{
Id = entry.Id ?? Guid.NewGuid(),
ProposalId = proposalId,
Description = entry.Description,
Quantity = entry.Quantity,
Unit = entry.Unit,
UnitPrice = entry.UnitPrice,
TotalPrice = entry.TotalPrice,
PricingMode = entry.PricingMode,
SortOrder = entry.SortOrder,
Source = entry.Source,
CreatedAt = now,
UpdatedAt = now,
}).ToList();
_db.LineItems.AddRange(newItems);
proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice);
proposal.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
}
public async Task DeleteAsync(Guid proposalId, Guid lineItemId, CancellationToken ct = default)
{
var lineItem = await _db.LineItems
.FirstOrDefaultAsync(li => li.Id == lineItemId && li.ProposalId == proposalId, ct)
?? throw new KeyNotFoundException($"Line item {lineItemId} not found");
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)!;
if (proposal!.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
_db.LineItems.Remove(lineItem);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Removed: {lineItem.Description}", ct);
}
private static LineItemResponse MapToResponse(LineItem li) => new(
li.Id,
li.ProposalId,
li.Description,
li.Quantity,
li.Unit,
li.UnitPrice,
li.TotalPrice,
li.PricingMode,
li.SortOrder,
li.Source,
li.CreatedAt,
li.UpdatedAt
);
}