← back to Norma
app/api/grants/[id]/proposals/[pid]/export/route.ts
152 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getBrand } from '@/lib/brand';
import { getOrgId } from '@/lib/orgId';
type Params = Promise<{ id: string; pid: string }>;
/**
* GET /api/grants/[id]/proposals/[pid]/export?format=html
* Export the proposal as downloadable HTML (styled for printing to PDF).
* Browser print-to-PDF gives better results than server-side PDF generation
* without heavy dependencies like Puppeteer.
*/
export async function GET(request: NextRequest, { params }: { params: Params }) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { id: grantId, pid } = await params;
try {
// Fetch proposal + grant
const proposalResult = await query(`SELECT * FROM grant_proposals WHERE id = $1`, [pid]);
if (proposalResult.rows.length === 0) {
return NextResponse.json({ error: 'Proposal not found' }, { status: 404 });
}
const proposal = proposalResult.rows[0];
const grantResult = await query(`SELECT title, funder FROM grants WHERE id = $1`, [grantId]);
const grant = grantResult.rows[0] || { title: 'Grant Proposal', funder: '' };
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const brand = await getBrand(orgId ?? undefined);
// Build a print-ready HTML document
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(proposal.subject)}</title>
<style>
@page { margin: 1in; size: letter; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.6;
color: #1a1a1a;
max-width: 7in;
margin: 0 auto;
padding: 0.5in;
}
.header {
text-align: center;
margin-bottom: 2em;
padding-bottom: 1em;
border-bottom: 2px solid #1e3a5f;
}
.header h1 {
font-size: 14pt;
color: #1e3a5f;
margin: 0 0 0.25em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.header p {
font-size: 10pt;
color: #666;
margin: 0;
}
.meta {
font-size: 10pt;
color: #555;
margin-bottom: 1.5em;
}
.meta strong { color: #333; }
h3 { color: #1e3a5f; font-size: 13pt; margin-top: 1.5em; }
p { margin: 0.75em 0; }
ul, ol { margin: 0.5em 0; padding-left: 1.5em; }
li { margin: 0.25em 0; }
.footer {
margin-top: 3em;
padding-top: 1em;
border-top: 1px solid #ccc;
font-size: 9pt;
color: #999;
text-align: center;
}
@media print {
body { padding: 0; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="no-print" style="background:#1e3a5f;color:#fff;padding:10px 20px;text-align:center;margin-bottom:20px;border-radius:4px;font-family:sans-serif;font-size:13px;">
Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong>) to save as PDF — then close this tab.
</div>
<div class="header">
<h1>${escapeHtml(brand.name)}</h1>
<p>Grant Proposal</p>
</div>
<div class="meta">
<strong>To:</strong> ${escapeHtml(proposal.recipient_name || grant.funder)}<br>
${proposal.recipient_email ? `<strong>Email:</strong> ${escapeHtml(proposal.recipient_email)}<br>` : ''}
<strong>Re:</strong> ${escapeHtml(grant.title)}<br>
<strong>Date:</strong> ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
${proposal.body_html}
<div class="footer">
${escapeHtml(brand.name)}${brand.website ? ` — ${escapeHtml(brand.website.replace(/^https?:\/\//, ''))}` : ''}
</div>
</body>
</html>`;
// Update status
await query(
`UPDATE grant_proposals SET exported_at = NOW() WHERE id = $1 AND exported_at IS NULL`,
[pid],
);
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
await auditLog('proposal.exported', 'grant_proposal', pid, { format: 'html' }, ip);
const safeFilename = (proposal.subject || 'proposal')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 60);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': `inline; filename="${safeFilename}.html"`,
},
});
} catch (err) {
console.error('[proposal/export] error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to export proposal' }, { status: 500 });
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}