← back to Freddy

app/api/contacts/discover/route.ts

142 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';
import { auditLog } from '@/lib/audit';

const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');

export async function POST(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    // Fetch context: top causes, orgs, and granters
    const [causesRes, orgsRes, grantersRes] = await Promise.all([
      query(`SELECT title, category, urgency_score FROM causes WHERE status = 'active' ORDER BY urgency_score DESC LIMIT 10`),
      query(`SELECT name, category, mission FROM organizations WHERE status IN ('verified', 'featured') LIMIT 10`),
      query(`SELECT name, funding_areas FROM granters WHERE status = 'active' LIMIT 10`),
    ]);

    const causesContext = causesRes.rows.map(c => c.title).join(', ');
    const orgsContext = orgsRes.rows.map(o => o.name).join(', ');
    const grantersContext = grantersRes.rows.map(g => g.name).join(', ');

    const prompt = `You are a fundraising consultant specializing in connecting non-profits with grant funders. Generate 8-12 realistic fictional contacts for a fundraising coordination platform.

CONTEXT:
- Active causes: ${causesContext || 'education, health, poverty, environment'}
- Registered orgs: ${orgsContext || 'various non-profits'}
- Active granters: ${grantersContext || 'various foundations'}

Generate contacts that include:
- Non-profit executive directors and program directors
- Foundation grant officers and program managers
- Board members of funding organizations
- Government grant coordinators

For each contact, provide:
- name: Full name (realistic)
- title: Job title
- organization: Their organization name
- contact_type: One of: nonprofit_leader, grant_officer, board_member, program_director, government, media
- email: Professional email address
- phone: Phone number (xxx-xxx-xxxx format)
- city: City
- state: State abbreviation
- relevance_score: 0.0-1.0 (how relevant to our causes)
- ai_pitch: A 1-2 sentence suggestion for how to approach this person

Return ONLY valid JSON array, no markdown:
[{"name":"...", "title":"...", "organization":"...", "contact_type":"...", "email":"...", "phone":"...", "city":"...", "state":"...", "relevance_score":0.85, "ai_pitch":"..."}]`;

    const geminiRes = await fetch(GEMINI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: { temperature: 0.8, maxOutputTokens: 4096 },
      }),
    });

    if (!geminiRes.ok) {
      const errText = await geminiRes.text();
      console.error('[contacts/discover] Gemini error:', errText);
      return NextResponse.json({ error: 'AI service error' }, { status: 502 });
    }

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

    let jsonStr = rawText.trim();
    if (jsonStr.startsWith('```')) {
      jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
    }

    let contacts: Array<{
      name: string;
      title: string;
      organization: string;
      contact_type: string;
      email: string;
      phone: string;
      city: string;
      state: string;
      relevance_score: number;
      ai_pitch: string;
    }>;

    try {
      contacts = JSON.parse(jsonStr);
    } catch {
      console.error('[contacts/discover] Parse error:', jsonStr.slice(0, 300));
      return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
    }

    const validTypes = ['nonprofit_leader', 'grant_officer', 'board_member', 'program_director', 'government', 'media', 'other'];

    const inserted = [];
    for (const contact of contacts) {
      const contactType = validTypes.includes(contact.contact_type) ? contact.contact_type : 'other';
      try {
        const result = await query(
          `INSERT INTO contacts (contact_type, name, title, organization, email, phone, city, state, source, relevance_score, ai_pitch)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ai_discovery', $9, $10)
           RETURNING *`,
          [
            contactType,
            contact.name,
            contact.title || null,
            contact.organization || null,
            contact.email || null,
            contact.phone || null,
            contact.city || null,
            contact.state || null,
            Math.min(1, Math.max(0, contact.relevance_score || 0.5)),
            contact.ai_pitch || null,
          ],
        );
        inserted.push(result.rows[0]);
      } catch (insertErr) {
        console.error('[contacts/discover] Insert error for:', contact.name, (insertErr as Error).message);
      }
    }

    await auditLog('contacts.discovered', 'contact', null, {
      user,
      discovered: contacts.length,
      inserted: inserted.length,
    });

    return NextResponse.json({
      discovered: contacts.length,
      inserted: inserted.length,
      contacts: inserted,
    });
  } catch (err) {
    console.error('[contacts/discover] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to discover contacts' }, { status: 500 });
  }
}