← back to Grant

app/api/collaborations/discover/route.ts

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

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

  try {
    const orgId = await resolveOrgId(session);
    if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
    const orgRes = await query(
      'SELECT id, name, ai_mission, ai_focus_areas, ai_keywords, ai_org_summary, city, state FROM organizations WHERE id = $1',
      [orgId],
    );
    if (orgRes.rows.length === 0) {
      return NextResponse.json({ error: 'No organization found' }, { status: 400 });
    }
    const org = orgRes.rows[0];

    // Get existing collaborators to avoid duplicates
    const existingRes = await query(
      'SELECT name, organization FROM collaborations WHERE org_id = $1',
      [org.id],
    );
    const existingNames = existingRes.rows.map(
      (r) => `${r.name} (${r.organization || 'N/A'})`,
    );

    const prompt = `You are a nonprofit partnerships and advocacy strategy consultant. Suggest 8-10 potential collaborators for this organization:

Organization: ${org.name}
Location: ${org.city}, ${org.state}
Mission: ${org.ai_mission}
Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
Keywords: ${(org.ai_keywords || []).join(', ')}
Summary: ${org.ai_org_summary}

${existingNames.length > 0 ? `Already connected (DO NOT repeat): ${existingNames.join('; ')}` : ''}

Suggest a mix of:
- 2-3 Nonprofits (real organizations working on student debt, education access, consumer protection)
- 2-3 Politicians (real US senators, representatives, or state officials who champion education/debt issues)
- 2 Corporations (companies with relevant CSR programs or education benefits)
- 1-2 Municipalities (city or county governments with student debt programs)

Return ONLY a JSON array (no markdown, no code fences) of objects with:
- collab_type: string (one of: "nonprofit", "politician", "corporation", "municipality")
- name: string (real person or organization contact name)
- title: string (their title/role)
- organization: string (their organization name)
- website_url: string (real website URL)
- email: string (plausible contact email, use info@ or press@ formats)
- district: string (for politicians, their district; for others, null)
- state: string (two-letter state code)
- ai_reason: string (2-3 sentences explaining why this is a valuable collaboration)
- ai_relevance: number (0-1 relevance score)
- ai_talking_points: string[] (3-4 specific talking points for initial outreach)`;

    // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
    const result = await callGemini<Record<string, unknown>[]>({ prompt, maxTokens: 8192 });
    if (!result.ok) {
      console.error('[collabs/discover] gemini failed:', result.reason, result.detail);
      return NextResponse.json(
        { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
        { status: result.status },
      );
    }
    const collabs = result.data;
    if (!Array.isArray(collabs)) {
      return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
    }

    const inserted = [];
    for (const c of collabs) {
      try {
        const result = await query(
          `INSERT INTO collaborations (org_id, collab_type, name, title, organization, website_url, email, district, state, ai_reason, ai_relevance, ai_talking_points, status)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'suggested')
           RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`,
          [
            org.id,
            c.collab_type || 'nonprofit',
            c.name || 'Unknown',
            c.title || null,
            c.organization || null,
            c.website_url || null,
            c.email || null,
            c.district || null,
            c.state || null,
            c.ai_reason || null,
            c.ai_relevance != null ? c.ai_relevance : 0.5,
            c.ai_talking_points || [],
          ],
        );
        inserted.push(result.rows[0]);
      } catch (insertErr) {
        console.error('[collabs/discover] Insert error:', c.name, insertErr);
      }
    }

    // 2026-05-05 (architect P2-3): audit AI call.
    auditLog('collaboration.ai_discovered', 'collaboration', null, org.id, user, {
      discovered_count: inserted.length,
      input_tokens: result.inputTokens,
      output_tokens: result.outputTokens,
    }, request.headers.get('x-forwarded-for') || undefined);

    return NextResponse.json({
      discovered: inserted.length,
      collaborations: inserted,
    });
  } catch (err) {
    console.error('[collabs/discover]', err);
    return NextResponse.json({ error: 'Collaboration discovery failed' }, { status: 500 });
  }
}