← back to Norma

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

126 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';

const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
const GEMINI_MODEL = 'gemini-2.0-flash';

/**
 * POST /api/grants/[id]/proposals/generate
 * AI-generates a grant proposal / Letter of Inquiry using grant data + org context.
 */
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id: grantId } = await params;

  try {
    // 1. Fetch grant details
    const grantResult = await query(`SELECT * FROM grants WHERE id = $1`, [grantId]);
    if (grantResult.rows.length === 0) {
      return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
    }
    const grant = grantResult.rows[0];

    // 2. Fetch org brand data and site context
    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
    const brand = await getBrand(orgId);
    const siteResult = await query(
      `SELECT title, content_text FROM site_documents
       WHERE source_type = 'web'
       ORDER BY scraped_at DESC LIMIT 5`,
    );
    const siteContext = siteResult.rows
      .map((r) => `## ${r.title}\n${(r.content_text || '').slice(0, 800)}`)
      .join('\n\n');

    // 3. Build the AI prompt
    const prompt = `You are a grant writing expert for ${brand.name}, a nonprofit organization. Write a professional Letter of Inquiry (LOI) / grant proposal.

## GRANT DETAILS
- Title: ${grant.title}
- Funder: ${grant.funder}
- Amount Range: ${grant.amount_min ? `$${grant.amount_min.toLocaleString()}` : 'TBD'} - ${grant.amount_max ? `$${grant.amount_max.toLocaleString()}` : 'TBD'}
- Deadline: ${grant.deadline ? new Date(grant.deadline).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : 'Rolling'}
- Focus Areas: ${(grant.focus_areas || []).join(', ') || 'General'}
- Description: ${grant.description || 'Not specified'}
- Eligibility: ${grant.eligibility || 'Not specified'}
${grant.ai_application_tips ? `- AI Application Tips: ${grant.ai_application_tips}` : ''}

## ABOUT ${brand.name} (from website)
${siteContext || (brand.mission ? brand.mission : `${brand.name} is a nonprofit organization dedicated to advocacy and community impact.`)}

## INSTRUCTIONS
Write a compelling, professional grant proposal letter that:
1. Opens with a strong hook about the organization's focus area
2. Clearly states the funding request amount and purpose
3. Describes the organization's mission, track record, and impact
4. Explains how the funding will be used (specific programs/initiatives aligned with the funder's focus)
5. Includes measurable outcomes and success metrics
6. Closes with a call to action and contact information

Format the output as HTML suitable for email. Use <p>, <h3>, <ul>, <li> tags. Keep it professional but passionate. Length: 600-900 words.

Do NOT include a subject line in the body — only the letter content.`;

    // 4. Call Gemini
    const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
    const geminiRes = await fetch(geminiUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: {
          temperature: 0.7,
          maxOutputTokens: 4096,
        },
      }),
    });

    if (!geminiRes.ok) {
      const errBody = await geminiRes.text();
      console.error(`[generate-proposal] Gemini ${geminiRes.status}:`, errBody.slice(0, 300));
      return NextResponse.json({ error: `AI generation failed (HTTP ${geminiRes.status})` }, { status: 502 });
    }

    const geminiData = await geminiRes.json();
    const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    // 5. Extract HTML content
    let bodyHtml = rawText.trim();
    // Strip markdown code fences if present
    bodyHtml = bodyHtml.replace(/^```html?\s*\n?/i, '').replace(/\n?```\s*$/, '').trim();

    // 6. Generate subject line
    const subjectLine = `Grant Proposal: ${grant.title} — ${brand.name}`;

    // 7. Create the proposal record
    const proposalResult = await query(
      `INSERT INTO grant_proposals (grant_id, subject, body_html, recipient_name, created_by)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING *`,
      [grantId, subjectLine, bodyHtml, grant.funder, auth.username],
    );

    const proposal = proposalResult.rows[0];

    const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
    await auditLog('proposal.generated', 'grant_proposal', proposal.id, {
      grant_id: grantId,
      grant_title: grant.title,
    }, ip);

    return NextResponse.json({ proposal }, { status: 201 });
  } catch (err) {
    console.error('[generate-proposal] error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to generate proposal' }, { status: 500 });
  }
}