← 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 });
}
}