Add PDF download for all roles, version history, and status timeline fix

- Removed admin-only restriction on PDF download endpoints
- Added GET pdf/versions endpoint returning all generated PDFs
- Download PDF button on detail page for Approved/Sent/Revised proposals
- PDF Versions card shows all revisions with individual download buttons
- Dev-mode support for GetPdfRevision endpoint
- Status timeline stepper now uses STATUS_LABELS (fixes "InReview" display)
- PDF Lambda improvements for local generation
This commit is contained in:
Adam Moussa 2026-05-22 16:40:19 -04:00
parent 666e2bad9e
commit cab97cbb1b
8 changed files with 280 additions and 28 deletions

View file

@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -94,40 +95,133 @@ public class FilesController : ControllerBase
}
[HttpGet("pdf")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default)
{
var pdf = await _db.GeneratedPdfs
var devMode = _config.GetValue<bool>("Auth:DevMode");
var pdf = regenerate ? null : await _db.GeneratedPdfs
.Where(p => p.ProposalId == proposalId)
.OrderByDescending(p => p.Revision)
.FirstOrDefaultAsync(ct);
if (pdf == null)
if (pdf != null && devMode)
{
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
return Accepted(new { message = "PDF generation queued" });
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));
}
}
var bucket = _config["GENERATED_BUCKET"]!;
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
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)));
}
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct);
if (devMode)
{
return await _generatePdfLocally(proposalId, ct);
}
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
return Accepted(new { message = "PDF generation queued" });
}
private async Task<ActionResult> _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<JsonElement>(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<ActionResult<IReadOnlyList<PdfVersionResponse>>> 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}")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
Guid proposalId,
int revision,
CancellationToken ct)
{
var devMode = _config.GetValue<bool>("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);

View file

@ -69,6 +69,14 @@ public class ProposalsController : ControllerBase
return Ok(result);
}
[HttpPost("{id:guid}/return-to-review")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> ReturnToReview(Guid id, CancellationToken ct)
{
var result = await _proposalService.ReturnToReviewAsync(id, ct);
return Ok(result);
}
[HttpPost("{id:guid}/send")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)

View file

@ -12,6 +12,11 @@ public record PdfDownloadResponse(
DateTime ExpiresAt
);
public record PdfVersionResponse(
int Revision,
DateTime GeneratedAt
);
public record VendorProposalResponse(
Guid Id,
string VendorName,

View file

@ -9,6 +9,7 @@ public interface IProposalService
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);

View file

@ -14,7 +14,8 @@ public enum AuditAction
GeneratePDF,
MarkSent,
CreateRevision,
UpdateRole
UpdateRole,
ReturnToReview
}
public class AuditLog

View file

@ -39,9 +39,9 @@ secrets_client = boto3.client("secretsmanager")
_cached_api_key: str | None = None
COMPANY_NAME = "Sea Haven Industries"
COMPANY_ADDRESS = "Sea Haven Industries LLC"
COMPANY_PHONE = ""
COMPANY_EMAIL = "info@seahavenind.com"
COMPANY_ADDRESS = "710 Koehler Ave, Ronkonkoma, NY 11779"
COMPANY_PHONE = "(631) 776-5102"
COMPANY_EMAIL = "work-orders@seahaven.com"
TERMS_AND_CONDITIONS = """
1. This proposal is valid for 30 days from the date of issue.
@ -285,13 +285,18 @@ def _build_header(proposal: dict, styles) -> list:
revision = proposal.get("currentRevision", 1)
revision_text = f" | Rev {revision}" if revision > 1 else ""
contact_lines = (
f"{COMPANY_ADDRESS}<br/>"
f"{COMPANY_PHONE} | {COMPANY_EMAIL}"
)
header_data = [
[
Paragraph(COMPANY_NAME, styles["CompanyName"]),
Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]),
],
[
Paragraph(f"{COMPANY_EMAIL}", styles["CompanyInfo"]),
Paragraph(contact_lines, styles["CompanyInfo"]),
Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]),
],
]
@ -328,10 +333,12 @@ def _build_metadata(proposal: dict, styles) -> list:
except (ValueError, TypeError):
pass
po_number = proposal.get("poNumber") or ""
meta_data = [
[
Paragraph("Customer", styles["MetaLabel"]),
Paragraph("Site Address", styles["MetaLabel"]),
Paragraph("Site", styles["MetaLabel"]),
],
[
Paragraph(proposal.get("customerName", ""), styles["MetaValue"]),
@ -339,19 +346,27 @@ def _build_metadata(proposal: dict, styles) -> list:
],
[
Paragraph("Work Order #", styles["MetaLabel"]),
Paragraph("Date", styles["MetaLabel"]),
Paragraph("PO #" if po_number else "", styles["MetaLabel"]),
],
[
Paragraph(proposal.get("workOrderNumber", ""), styles["MetaValue"]),
Paragraph(approved_at or submitted_at, styles["MetaValue"]),
Paragraph(po_number, styles["MetaValue"]),
],
[
Paragraph("Date", styles["MetaLabel"]),
Paragraph("Category", styles["MetaLabel"]),
Paragraph("Priority", styles["MetaLabel"]),
],
[
Paragraph(approved_at or submitted_at, styles["MetaValue"]),
Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]),
],
[
Paragraph("Priority", styles["MetaLabel"]),
Paragraph("", styles["MetaLabel"]),
],
[
Paragraph(proposal.get("priority", ""), styles["MetaValue"]),
Paragraph("", styles["MetaValue"]),
],
]

View file

@ -2,6 +2,7 @@ import apiClient from './client';
export interface CreateProposalRequest {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
@ -28,6 +29,7 @@ export interface ProposalDetail {
id: string;
proposalNumber: string;
workOrderNumber: string;
poNumber: string | null;
customerName: string;
customerAddress: string;
scopeOfWork: string;
@ -112,6 +114,33 @@ export const proposalsApi = {
const res = await apiClient.get('/proposals/stats');
return res.data;
},
getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
getPdfVersions: async (id: string): Promise<PdfVersion[]> => {
const res = await apiClient.get(`/proposals/${id}/pdf/versions`);
return res.data;
},
getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};
export interface ProposalStats {
@ -120,3 +149,8 @@ export interface ProposalStats {
approvedCount: number;
sentCount: number;
}
export interface PdfVersion {
revision: number;
generatedAt: string;
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@ -9,14 +10,20 @@ import {
Chip,
Divider,
Button,
IconButton,
Skeleton,
Stepper,
Step,
StepLabel,
List,
ListItem,
ListItemText,
Tooltip,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { STATUS_COLORS } from '../../../constants';
import DownloadIcon from '@mui/icons-material/Download';
import { proposalsApi, type ProposalDetail, type PdfVersion } from '../../../lib/api/proposals';
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -33,9 +40,13 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
const DOWNLOADABLE_STATUSES = ['Approved', 'Sent', 'Revised'];
export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
const [downloadingRevision, setDownloadingRevision] = useState<number | null>(null);
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
queryKey: ['proposals', id],
@ -43,8 +54,48 @@ export default function ProposalDetailPage() {
enabled: !!id,
});
const { data: pdfVersions } = useQuery<PdfVersion[]>({
queryKey: ['proposals', id, 'pdf-versions'],
queryFn: () => proposalsApi.getPdfVersions(id!),
enabled: !!id && !!proposal && DOWNLOADABLE_STATUSES.includes(proposal.status),
});
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
const handleDownload = async () => {
if (!id || !proposal) return;
setDownloading(true);
try {
const result = await proposalsApi.getPdf(id);
if (result) {
const a = document.createElement('a');
a.href = result.downloadUrl;
a.download = `${proposal.proposalNumber}.pdf`;
a.click();
URL.revokeObjectURL(result.downloadUrl);
}
} finally {
setDownloading(false);
}
};
const handleDownloadRevision = async (revision: number) => {
if (!id || !proposal) return;
setDownloadingRevision(revision);
try {
const result = await proposalsApi.getPdfRevision(id, revision);
if (result) {
const a = document.createElement('a');
a.href = result.downloadUrl;
a.download = `${proposal.proposalNumber}-rev${revision}.pdf`;
a.click();
URL.revokeObjectURL(result.downloadUrl);
}
} finally {
setDownloadingRevision(null);
}
};
if (isLoading) {
return (
<Box>
@ -78,8 +129,18 @@ export default function ProposalDetailPage() {
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
{DOWNLOADABLE_STATUSES.includes(proposal.status) && (
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownload}
disabled={downloading}
>
{downloading ? 'Downloading...' : 'Download PDF'}
</Button>
)}
<Chip
label={proposal.status}
label={STATUS_LABELS[proposal.status] || proposal.status}
color={STATUS_COLORS[proposal.status] || 'default'}
size="medium"
/>
@ -98,7 +159,7 @@ export default function ProposalDetailPage() {
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
<StepLabel>{STATUS_LABELS[label] || label}</StepLabel>
</Step>
))}
</Stepper>
@ -112,11 +173,11 @@ export default function ProposalDetailPage() {
Proposal Details
</Typography>
<Grid container spacing={2}>
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
<InfoRow label="Work Order Number" value={proposal.workOrderNumber} />
<InfoRow label="Customer" value={proposal.customerName} />
<InfoRow label="Address" value={proposal.customerAddress} />
<InfoRow label="Site" value={proposal.customerAddress} />
<InfoRow label="Category" value={proposal.serviceCategory} />
<InfoRow label="Priority" value={proposal.priority} />
<InfoRow label="Priority" value={PRIORITY_LABELS[proposal.priority] || proposal.priority} />
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
<InfoRow label="Submitted By" value={proposal.submittedByName} />
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
@ -147,7 +208,7 @@ export default function ProposalDetailPage() {
</Card>
{(proposal.approvedAt || proposal.sentAt) && (
<Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Approval & Delivery
@ -162,6 +223,39 @@ export default function ProposalDetailPage() {
</CardContent>
</Card>
)}
{pdfVersions && pdfVersions.length > 1 && (
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
PDF Versions
</Typography>
<List dense disablePadding>
{pdfVersions.map((v) => (
<ListItem
key={v.revision}
secondaryAction={
<Tooltip title={`Download Rev ${v.revision}`}>
<IconButton
edge="end"
onClick={() => handleDownloadRevision(v.revision)}
disabled={downloadingRevision === v.revision}
>
<DownloadIcon />
</IconButton>
</Tooltip>
}
>
<ListItemText
primary={`Revision ${v.revision}${v.revision === proposal.currentRevision ? ' (current)' : ''}`}
secondary={formatDateTime(v.generatedAt)}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
</Box>
);
}