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; /// /// PR5: Pricing Library CRUD service. Publishes "library-ingest" SQS jobs /// on create/update so the Bedrock KB corpus stays in sync. /// public class PricingLibraryService : IPricingLibraryService { private readonly ProposalDbContext _db; private readonly IJobPublisher _jobPublisher; private readonly ILogger _logger; public PricingLibraryService( ProposalDbContext db, IJobPublisher jobPublisher, ILogger logger) { _db = db; _jobPublisher = jobPublisher; _logger = logger; } public async Task> ListAsync(int page, int pageSize, CancellationToken ct = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var query = _db.PricingLibraryItems.AsNoTracking().AsQueryable(); var totalCount = await query.CountAsync(ct); var items = await query .OrderBy(i => i.Title) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); return new PagedResponse( items.Select(MapToResponse).ToList(), totalCount, page, pageSize ); } public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { var item = await _db.PricingLibraryItems .AsNoTracking() .FirstOrDefaultAsync(i => i.Id == id, ct); return item == null ? null : MapToResponse(item); } public async Task CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default) { var now = DateTime.UtcNow; var item = new PricingLibraryItem { Id = Guid.NewGuid(), Title = request.Title, Description = request.Description, ServiceCategory = request.ServiceCategory, Unit = request.Unit, UnitPrice = request.UnitPrice, Keywords = request.Keywords, Source = request.Source ?? "Manual", CreatedAt = now, UpdatedAt = now, }; _db.PricingLibraryItems.Add(item); await _db.SaveChangesAsync(ct); _logger.LogInformation("PricingLibraryItem {ItemId} created with title '{Title}'", item.Id, item.Title); // PR5: Trigger KB ingestion so the Bedrock RAG corpus picks up the new item. // Wrap in try/catch so a publish failure does not roll back the save // (matches the audit-no-rollback convention from API-H6). try { await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id); } return MapToResponse(item); } public async Task UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default) { var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct); if (item == null) return null; if (request.Title != null) item.Title = request.Title; if (request.Description != null) item.Description = request.Description; if (request.ServiceCategory.HasValue) item.ServiceCategory = request.ServiceCategory.Value; if (request.Unit != null) item.Unit = request.Unit; if (request.UnitPrice.HasValue) item.UnitPrice = request.UnitPrice.Value; if (request.Keywords != null) item.Keywords = request.Keywords; item.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); _logger.LogInformation("PricingLibraryItem {ItemId} updated", item.Id); // PR5: Trigger KB re-ingestion so the Bedrock RAG corpus reflects the update. // Wrap in try/catch so a publish failure does not roll back the save // (matches the audit-no-rollback convention from API-H6). try { await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id); } return MapToResponse(item); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct); if (item == null) return false; _db.PricingLibraryItems.Remove(item); await _db.SaveChangesAsync(ct); _logger.LogInformation("PricingLibraryItem {ItemId} deleted", item.Id); // TODO: PR5 follow-up — remove the corresponding markdown document from the // Bedrock Knowledge Base S3 bucket so stale items are not returned by RAG queries. return true; } private static PricingLibraryItemResponse MapToResponse(PricingLibraryItem item) => new( item.Id, item.Title, item.Description, item.ServiceCategory, item.Unit, item.UnitPrice, item.Keywords, item.Source, item.CreatedAt, item.UpdatedAt ); }