← 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 &mdash; 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 ? ` &mdash; ${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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}