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 System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -94,40 +95,133 @@ public class FilesController : ControllerBase
} }
[HttpGet("pdf")] [HttpGet("pdf")]
[Authorize(Roles = "admins,sysadmins")] public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default)
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
{ {
var pdf = await _db.GeneratedPdfs var devMode = _config.GetValue<bool>("Auth:DevMode");
var pdf = regenerate ? null : await _db.GeneratedPdfs
.Where(p => p.ProposalId == proposalId) .Where(p => p.ProposalId == proposalId)
.OrderByDescending(p => p.Revision) .OrderByDescending(p => p.Revision)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (pdf == null) if (pdf != null && devMode)
{ {
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct); var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key);
return Accepted(new { message = "PDF generation queued" }); 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"]!; if (pdf != null && !devMode)
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); {
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}")] [HttpGet("pdf/{revision:int}")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision( public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
Guid proposalId, Guid proposalId,
int revision, int revision,
CancellationToken ct) CancellationToken ct)
{ {
var devMode = _config.GetValue<bool>("Auth:DevMode");
var pdf = await _db.GeneratedPdfs var pdf = await _db.GeneratedPdfs
.FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct); .FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct);
if (pdf == null) return NotFound(); 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 bucket = _config["GENERATED_BUCKET"]!;
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);

View file

@ -69,6 +69,14 @@ public class ProposalsController : ControllerBase
return Ok(result); 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")] [HttpPost("{id:guid}/send")]
[Authorize(Roles = "admins,sysadmins")] [Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct) public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import apiClient from './client';
export interface CreateProposalRequest { export interface CreateProposalRequest {
workOrderNumber: string; workOrderNumber: string;
poNumber?: string;
customerName: string; customerName: string;
customerAddress: string; customerAddress: string;
scopeOfWork: string; scopeOfWork: string;
@ -28,6 +29,7 @@ export interface ProposalDetail {
id: string; id: string;
proposalNumber: string; proposalNumber: string;
workOrderNumber: string; workOrderNumber: string;
poNumber: string | null;
customerName: string; customerName: string;
customerAddress: string; customerAddress: string;
scopeOfWork: string; scopeOfWork: string;
@ -112,6 +114,33 @@ export const proposalsApi = {
const res = await apiClient.get('/proposals/stats'); const res = await apiClient.get('/proposals/stats');
return res.data; 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 { export interface ProposalStats {
@ -120,3 +149,8 @@ export interface ProposalStats {
approvedCount: number; approvedCount: number;
sentCount: 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 { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@ -9,14 +10,20 @@ import {
Chip, Chip,
Divider, Divider,
Button, Button,
IconButton,
Skeleton, Skeleton,
Stepper, Stepper,
Step, Step,
StepLabel, StepLabel,
List,
ListItem,
ListItemText,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import DownloadIcon from '@mui/icons-material/Download';
import { STATUS_COLORS } from '../../../constants'; 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 { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; 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() { export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
const [downloadingRevision, setDownloadingRevision] = useState<number | null>(null);
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({ const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
queryKey: ['proposals', id], queryKey: ['proposals', id],
@ -43,8 +54,48 @@ export default function ProposalDetailPage() {
enabled: !!id, 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'); 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) { if (isLoading) {
return ( return (
<Box> <Box>
@ -78,8 +129,18 @@ export default function ProposalDetailPage() {
<Typography variant="h5" sx={{ flexGrow: 1 }}> <Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber} {proposal.proposalNumber}
</Typography> </Typography>
{DOWNLOADABLE_STATUSES.includes(proposal.status) && (
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownload}
disabled={downloading}
>
{downloading ? 'Downloading...' : 'Download PDF'}
</Button>
)}
<Chip <Chip
label={proposal.status} label={STATUS_LABELS[proposal.status] || proposal.status}
color={STATUS_COLORS[proposal.status] || 'default'} color={STATUS_COLORS[proposal.status] || 'default'}
size="medium" size="medium"
/> />
@ -98,7 +159,7 @@ export default function ProposalDetailPage() {
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel> <Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => ( {STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}> <Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel> <StepLabel>{STATUS_LABELS[label] || label}</StepLabel>
</Step> </Step>
))} ))}
</Stepper> </Stepper>
@ -112,11 +173,11 @@ export default function ProposalDetailPage() {
Proposal Details Proposal Details
</Typography> </Typography>
<Grid container spacing={2}> <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="Customer" value={proposal.customerName} />
<InfoRow label="Address" value={proposal.customerAddress} /> <InfoRow label="Site" value={proposal.customerAddress} />
<InfoRow label="Category" value={proposal.serviceCategory} /> <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="Revision" value={`#${proposal.currentRevision}`} />
<InfoRow label="Submitted By" value={proposal.submittedByName} /> <InfoRow label="Submitted By" value={proposal.submittedByName} />
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} /> <InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
@ -147,7 +208,7 @@ export default function ProposalDetailPage() {
</Card> </Card>
{(proposal.approvedAt || proposal.sentAt) && ( {(proposal.approvedAt || proposal.sentAt) && (
<Card> <Card sx={{ mb: 3 }}>
<CardContent> <CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}> <Typography variant="subtitle2" sx={{ mb: 2 }}>
Approval & Delivery Approval & Delivery
@ -162,6 +223,39 @@ export default function ProposalDetailPage() {
</CardContent> </CardContent>
</Card> </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> </Box>
); );
} }