← back to Grant

app/api/grants/[id]/proposals/route.ts

200 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
import { auditLog } from '@/lib/audit';
import { callGemini } from '@/lib/gemini';
import { sanitizeProposalBody, bodyHtmlTooLarge } from '@/lib/sanitize';

type ProposalShape = {
  subject?: string;
  recipient_name?: string;
  body_html?: string;
  body_text?: string;
};

// 2026-05-06 (tick 48): SANITIZE_OPTS extracted to lib/sanitize.ts as
// the canonical allowlist. Future routes that store user-supplied HTML
// should import sanitizeProposalBody from there.

/* ─── GET /api/grants/[id]/proposals ──────────────────────────────────────── */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = verifyAuthWithOrg(request);
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const { id } = await params;
    // 2026-05-04 (code-reviewer P0-4): tenant isolation via JOIN through grants.
    const orgId = await resolveOrgId(session);
    if (!orgId) return NextResponse.json({ proposals: [] });
    const result = await query(
      `SELECT gp.id, gp.grant_id, gp.org_id, gp.subject, gp.recipient_email, gp.recipient_name, gp.body_html, gp.body_text, gp.proposal_type, gp.status, gp.version, gp.sent_at, gp.created_at, gp.updated_at
       FROM grant_proposals gp
       JOIN grants g ON g.id = gp.grant_id
       WHERE gp.grant_id = $1 AND g.org_id = $2
       ORDER BY gp.version DESC, gp.created_at DESC`,
      [id, orgId],
    );
    return NextResponse.json({ proposals: result.rows });
  } catch (err) {
    console.error('[proposals GET]', err);
    return NextResponse.json({ error: 'Failed to fetch proposals' }, { status: 500 });
  }
}

/* ─── POST /api/grants/[id]/proposals ─────────────────────────────────────── */
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = verifyAuthWithOrg(request);
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const user = session.username;

  try {
    const { id: grantId } = await params;
    const body = await request.json();
    const proposalType = body.proposal_type || 'letter_of_inquiry';
    // 2026-05-04 (code-reviewer P1-2): cap user-controlled context interpolated
    // into the Gemini prompt. Unbounded input is a prompt-injection / cost-
    // amplification surface (multi-MB string → multi-MB prompt).
    const MAX_CONTEXT_CHARS = 2000;
    const context = String(body.context || '').slice(0, MAX_CONTEXT_CHARS);

    // 2026-05-30 (audit P2-3): tenant isolation — fetch the grant scoped to
    // the caller's org so a user can't generate a proposal against another
    // org's grant (the insert below uses grant.org_id).
    const orgId = await resolveOrgId(session);
    if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });

    // Get grant details
    const grantRes = await query('SELECT id, org_id, title, funder, amount_min, amount_max, description, eligibility, deadline, ai_suggestion FROM grants WHERE id = $1 AND org_id = $2', [grantId, orgId]);
    if (grantRes.rows.length === 0) {
      return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
    }
    const grant = grantRes.rows[0];

    // Get org details
    const orgRes = await query(
      'SELECT id, name, ai_mission, ai_focus_areas, ai_org_summary, city, state, nonprofit_status, staff_names FROM organizations WHERE id = $1',
      [grant.org_id],
    );
    const org = orgRes.rows[0];

    // If body_html is provided, just save it
    if (body.body_html && body.subject) {
      // 2026-05-05 (P1-3 final): size cap + sanitize-html allowlist.
      // The 200KB cap stops cost amplification; sanitize-html strips XSS
      // vectors so a future component using dangerouslySetInnerHTML can't
      // render malicious payloads. Allowlist matches the formatting tags
      // the AI prompt asks for.
      if (bodyHtmlTooLarge(body.body_html)) {
        return NextResponse.json({ error: 'body_html too large or invalid' }, { status: 400 });
      }
      const cleanHtml = sanitizeProposalBody(body.body_html);
      const result = await query(
        `INSERT INTO grant_proposals (grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft')
         RETURNING id, grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status, version, sent_at, created_at, updated_at`,
        [
          grantId,
          org.id,
          body.subject,
          body.recipient_email || null,
          body.recipient_name || null,
          cleanHtml,
          body.body_text || cleanHtml.replace(/<[^>]+>/g, ''),
          proposalType,
        ],
      );
      return NextResponse.json(result.rows[0], { status: 201 });
    }

    // Generate with AI
    const typeLabels: Record<string, string> = {
      letter_of_inquiry: 'Letter of Inquiry (LOI)',
      full_proposal: 'Full Grant Proposal',
      one_pager: 'One-Page Summary',
      meeting_request: 'Meeting Request Email',
    };

    const prompt = `You are a professional nonprofit fundraising writer. Generate a ${typeLabels[proposalType] || 'Letter of Inquiry'} for this grant application.

GRANT DETAILS:
- Grant: ${grant.title}
- Funder: ${grant.funder}
- Amount Range: $${grant.amount_min?.toLocaleString() || 'N/A'} - $${grant.amount_max?.toLocaleString() || 'N/A'}
- Description: ${grant.description || 'N/A'}
- Eligibility: ${grant.eligibility || 'N/A'}
- Deadline: ${grant.deadline || 'Rolling'}
- AI Suggestion: ${grant.ai_suggestion || 'N/A'}

ORGANIZATION:
- Name: ${org.name}
- Location: ${org.city}, ${org.state}
- Status: ${org.nonprofit_status}
- Mission: ${org.ai_mission}
- Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
- Summary: ${org.ai_org_summary}
- Key Staff: ${(org.staff_names || []).join(', ')}

${context ? `ADDITIONAL CONTEXT: ${context}` : ''}

Return ONLY a JSON object (no markdown, no code fences) with:
- subject: string (email subject line)
- recipient_name: string (appropriate contact title, e.g., "Grant Review Committee" or "Program Officer")
- body_html: string (the full ${typeLabels[proposalType] || 'letter'} formatted in HTML with proper paragraphs, using <p>, <strong>, <ul>, <li> tags. Professional nonprofit fundraising tone. Include specific details about how the organization's work aligns with the funder's priorities.)
- body_text: string (plain text version)`;

    // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
    const geminiResult = await callGemini<ProposalShape>({ prompt, maxTokens: 4096 });
    if (!geminiResult.ok) {
      console.error('[proposals/generate] gemini failed:', geminiResult.reason, geminiResult.detail);
      return NextResponse.json(
        { error: geminiResult.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
        { status: geminiResult.status },
      );
    }
    const proposal = geminiResult.data;

    // Get current max version for this grant
    const versionRes = await query(
      'SELECT COALESCE(MAX(version), 0) as max_version FROM grant_proposals WHERE grant_id = $1',
      [grantId],
    );
    const nextVersion = (versionRes.rows[0].max_version || 0) + 1;

    const result = await query(
      `INSERT INTO grant_proposals (grant_id, org_id, subject, recipient_name, body_html, body_text, proposal_type, status, version)
       VALUES ($1, $2, $3, $4, $5, $6, $7, 'draft', $8)
       RETURNING id, grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status, version, sent_at, created_at, updated_at`,
      [
        grantId,
        org.id,
        proposal.subject || `${typeLabels[proposalType]} - ${org.name}`,
        proposal.recipient_name || 'Grant Review Committee',
        proposal.body_html || '<p>Failed to generate proposal content.</p>',
        proposal.body_text || 'Failed to generate proposal content.',
        proposalType,
        nextVersion,
      ],
    );

    // 2026-05-05 (architect P2-3): audit AI calls — gives a trail if the
    // Gemini key is ever abused.
    auditLog('proposal.ai_generated', 'grant_proposal', result.rows[0].id, org.id, user, {
      grant_id: grantId,
      proposal_type: proposalType,
      input_tokens: geminiResult.inputTokens,
      output_tokens: geminiResult.outputTokens,
    }, request.headers.get('x-forwarded-for') || undefined);

    return NextResponse.json(result.rows[0], { status: 201 });
  } catch (err) {
    console.error('[proposals POST]', err);
    return NextResponse.json({ error: 'Failed to create proposal' }, { status: 500 });
  }
}