using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; using ProposalSystem.Infrastructure.Data; namespace ProposalSystem.Api.Controllers; [ApiController] [Route("api/proposals/{proposalId:guid}")] [Authorize] public class FilesController : ControllerBase { private readonly ProposalDbContext _db; private readonly IS3Service _s3; private readonly IJobPublisher _jobPublisher; private readonly IAuditService _audit; private readonly IConfiguration _config; public FilesController( ProposalDbContext db, IS3Service s3, IJobPublisher jobPublisher, IAuditService audit, IConfiguration config) { _db = db; _s3 = s3; _jobPublisher = jobPublisher; _audit = audit; _config = config; } // Fix: API-M6 — max file size for presigned upload URLs (25 MB) private const long MaxFileSizeBytes = 25 * 1024 * 1024; [HttpPost("attachments")] public async Task> UploadAttachment( Guid proposalId, [FromQuery] string fileName, [FromQuery] string? vendorName, [FromQuery] long? fileSize, CancellationToken ct) { // Fix: API-M6 — reject uploads exceeding 25 MB if (fileSize.HasValue && fileSize.Value > MaxFileSizeBytes) return BadRequest(new { error = $"File size exceeds maximum allowed size of {MaxFileSizeBytes / (1024 * 1024)} MB" }); var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct); if (proposal == null) return NotFound(); var extension = Path.GetExtension(fileName).ToLowerInvariant(); if (extension != ".pdf") return BadRequest(new { error = "Only PDF files are accepted" }); var s3Key = $"vendors/{proposalId}/{Guid.NewGuid()}{extension}"; var bucket = _config["UPLOADS_BUCKET"]!; var url = await _s3.GeneratePresignedUploadUrlAsync(bucket, s3Key, "application/pdf"); var vendorProposal = new VendorProposal { Id = Guid.NewGuid(), ProposalId = proposalId, VendorName = vendorName ?? string.Empty, FileName = fileName, S3Key = s3Key, UploadedAt = DateTime.UtcNow, ProcessingStatus = ProcessingStatus.Pending, }; _db.VendorProposals.Add(vendorProposal); await _db.SaveChangesAsync(ct); return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15), vendorProposal.Id)); } [HttpPost("attachments/{vendorProposalId:guid}/confirm")] public async Task ConfirmUpload( Guid proposalId, Guid vendorProposalId, CancellationToken ct) { var vendorProposal = await _db.VendorProposals .FirstOrDefaultAsync(v => v.Id == vendorProposalId && v.ProposalId == proposalId, ct); if (vendorProposal == null) return NotFound(); if (vendorProposal.ProcessingStatus != ProcessingStatus.Pending) return Ok(); await _jobPublisher.PublishAsync("pdf-extract", new { proposalId, s3Key = vendorProposal.S3Key, vendorProposalId = vendorProposal.Id, }, ct); return Ok(); } [HttpGet("pdf")] public async Task> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default) { var devMode = _config.GetValue("Auth:DevMode"); var pdf = regenerate ? null : await _db.GeneratedPdfs .Where(p => p.ProposalId == proposalId) .OrderByDescending(p => p.Revision) .FirstOrDefaultAsync(ct); if (pdf != null && devMode) { var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key); if (System.IO.File.Exists(localPath)) { await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct); return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key)); } } if (pdf != null && !devMode) { var bucket = _config["GENERATED_BUCKET"]!; var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct); return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); } if (devMode) { return await _generatePdfLocally(proposalId, ct); } await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct); return Accepted(new { message = "PDF generation queued" }); } private async Task _generatePdfLocally(Guid proposalId, CancellationToken ct) { var outputDir = _getGeneratedPdfsDir(); var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..")); var scriptPath = Path.Combine(repoRoot, "scripts", "generate-pdf-local.py"); var psi = new ProcessStartInfo { FileName = "python3", Arguments = $"\"{scriptPath}\" {proposalId} \"{outputDir}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; using var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(ct); var stderr = await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); if (process.ExitCode != 0) return StatusCode(500, new { message = "PDF generation failed", detail = stderr }); var result = JsonSerializer.Deserialize(stdout.Trim()); var filePath = result.GetProperty("path").GetString()!; var s3Key = result.GetProperty("s3Key").GetString()!; var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct); if (proposal == null) return NotFound(); var generatedPdf = new GeneratedPdf { Id = Guid.NewGuid(), ProposalId = proposalId, Revision = proposal.CurrentRevision, S3Key = s3Key, GeneratedAt = DateTime.UtcNow, GeneratedById = Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value), }; _db.GeneratedPdfs.Add(generatedPdf); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.GeneratePDF, proposalId, null, ct); return PhysicalFile(filePath, "application/pdf", Path.GetFileName(filePath)); } private string _getGeneratedPdfsDir() { var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..")); return Path.Combine(repoRoot, "generated-pdfs"); } [HttpGet("pdf/versions")] public async Task>> GetPdfVersions( Guid proposalId, CancellationToken ct) { var pdfs = await _db.GeneratedPdfs .Where(p => p.ProposalId == proposalId) .OrderByDescending(p => p.Revision) .Select(p => new PdfVersionResponse(p.Revision, p.GeneratedAt)) .ToListAsync(ct); return Ok(pdfs); } [HttpGet("pdf/{revision:int}")] public async Task> GetPdfRevision( Guid proposalId, int revision, CancellationToken ct) { var devMode = _config.GetValue("Auth:DevMode"); var pdf = await _db.GeneratedPdfs .FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct); if (pdf == null) return NotFound(); if (devMode) { var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key); if (System.IO.File.Exists(localPath)) { await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {revision}", ct); return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key)); } return NotFound(); } var bucket = _config["GENERATED_BUCKET"]!; var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {revision}", ct); return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); } [HttpGet("vendors")] [Authorize(Roles = "admins,sysadmins")] public async Task>> 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(v.ExtractedData) )).ToList(); return Ok(vendors); } }