← back to Norma

app/api/contacts/enrich/route.ts

230 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
import { matchContactToOrgs } from '@/lib/contact-matcher';
import { createContactLinks } from '@/lib/contact-links';

const GEMINI_KEY = process.env.GEMINI_API_KEY || '';

interface WorkEntry { company: string; title: string; start_year?: number; end_year?: number | null; description?: string; location?: string }
interface EduEntry { school: string; degree?: string; field?: string; start_year?: number; end_year?: number }

/**
 * Call Gemini to enrich a contact with professional data.
 */
async function enrichWithGemini(firstName: string, lastName: string, company: string | null, position: string | null): Promise<{
  headline: string; summary: string; website: string | null;
  skills: string[]; groups: string[];
  work_history: WorkEntry[]; education: EduEntry[];
} | null> {
  const prompt = `Find professional information about "${firstName} ${lastName}"${company ? `, currently at "${company}"` : ''}${position ? ` as "${position}"` : ''}.

Return ONLY valid JSON (no markdown, no code blocks):
{
  "headline": "professional headline (1 line)",
  "summary": "2-3 sentence professional bio",
  "website": "personal/company website URL or null",
  "skills": ["skill1", "skill2", "skill3"],
  "groups": ["professional association or group name"],
  "work_history": [
    {"company": "Company Name", "title": "Job Title", "start_year": 2020, "end_year": null, "description": "brief role desc", "location": "City, State"}
  ],
  "education": [
    {"school": "University Name", "degree": "MBA", "field": "Business", "start_year": 2016, "end_year": 2018}
  ]
}

If you have no information about this person, return:
{"headline": "", "summary": "", "website": null, "skills": [], "groups": [], "work_history": [], "education": []}`;

  try {
    const res = await fetch(
      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          contents: [{ parts: [{ text: prompt }] }],
          generationConfig: { temperature: 0.1, maxOutputTokens: 1000 },
        }),
      },
    );
    if (!res.ok) return null;

    const data = await res.json();
    const text = (data?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
    const jsonStr = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
    return JSON.parse(jsonStr);
  } catch {
    return null;
  }
}

/**
 * POST /api/contacts/enrich
 * Enrich contacts with AI-discovered professional data.
 *
 * Body:
 *   contact_id  - enrich a single contact
 *   batch       - true to enrich next N pending contacts
 *   limit       - batch size (default 10, max 20)
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const body = await request.json();
    const contactId = body.contact_id;
    const batch = body.batch === true;
    const limit = Math.min(parseInt(body.limit || '10'), 20);

    interface ContactRow { id: string; first_name: string; last_name: string; company: string | null; position: string | null }
    let contacts: ContactRow[] = [];

    if (contactId) {
      const r = await query(
        `SELECT id, first_name, last_name, company, position FROM contacts WHERE id = $1${orgId ? ' AND org_id = $2' : ''}`,
        orgId ? [contactId, orgId] : [contactId],
      );
      if (r.rowCount === 0) return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
      contacts = r.rows as ContactRow[];
    } else if (batch) {
      const r = await query(
        `SELECT id, first_name, last_name, company, position FROM contacts
         WHERE enrichment_status = 'pending'${orgId ? ' AND org_id = $2' : ''}
         ORDER BY created_at ASC LIMIT $1`,
        orgId ? [limit, orgId] : [limit],
      );
      contacts = r.rows as ContactRow[];
    } else {
      return NextResponse.json({ error: 'Provide contact_id or set batch=true' }, { status: 400 });
    }

    const results: Array<{ id: string; name: string; enriched: boolean; work_count: number; edu_count: number }> = [];

    for (const c of contacts) {
      const enriched = await enrichWithGemini(c.first_name, c.last_name, c.company, c.position);

      if (!enriched || (!enriched.headline && enriched.work_history.length === 0)) {
        await query(
          `UPDATE contacts SET enrichment_status = 'failed', enriched_at = NOW() WHERE id = $1`,
          [c.id],
        );
        results.push({ id: c.id, name: `${c.first_name} ${c.last_name}`, enriched: false, work_count: 0, edu_count: 0 });
        continue;
      }

      // Update contact main record
      await query(
        `UPDATE contacts SET
           headline = COALESCE($1, headline),
           summary = COALESCE($2, summary),
           website = COALESCE($3, website),
           skills = $4,
           groups = $5,
           enrichment_status = 'enriched',
           enrichment_source = 'gemini',
           enriched_at = NOW(),
           enrichment_data = $6
         WHERE id = $7`,
        [
          enriched.headline || null,
          enriched.summary || null,
          enriched.website || null,
          enriched.skills || [],
          enriched.groups || [],
          JSON.stringify(enriched),
          c.id,
        ],
      );

      // Insert work history
      let workCount = 0;
      for (const wh of enriched.work_history) {
        if (!wh.company) continue;
        const startDate = wh.start_year ? `${wh.start_year}-01-01` : null;
        const endDate = wh.end_year ? `${wh.end_year}-01-01` : null;
        await query(
          `INSERT INTO contact_work_history (contact_id, company_name, title, start_date, end_date, is_current, description, location)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
           ON CONFLICT DO NOTHING`,
          [c.id, wh.company, wh.title || null, startDate, endDate, !wh.end_year, wh.description || null, wh.location || null],
        );
        workCount++;
      }

      // Insert education
      let eduCount = 0;
      for (const edu of enriched.education) {
        if (!edu.school) continue;
        await query(
          `INSERT INTO contact_education (contact_id, school, degree, field_of_study, start_year, end_year)
           VALUES ($1, $2, $3, $4, $5, $6)
           ON CONFLICT DO NOTHING`,
          [c.id, edu.school, edu.degree || null, edu.field || null, edu.start_year || null, edu.end_year || null],
        );
        eduCount++;
      }

      // Insert groups
      for (const grp of enriched.groups) {
        if (!grp) continue;
        await query(
          `INSERT INTO contact_groups (contact_id, group_name, group_type)
           VALUES ($1, $2, 'professional')
           ON CONFLICT (contact_id, LOWER(group_name)) DO NOTHING`,
          [c.id, grp],
        );
      }

      // Re-run org matching with new work history
      await matchContactToOrgs(c.id);
      await createContactLinks(c.id);

      results.push({
        id: c.id, name: `${c.first_name} ${c.last_name}`,
        enriched: true, work_count: workCount, edu_count: eduCount,
      });
    }

    const enrichedCount = results.filter(r => r.enriched).length;
    return NextResponse.json({ results, enriched: enrichedCount, total: results.length });
  } catch (err) {
    console.error('[api/contacts/enrich] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Enrichment failed' }, { status: 500 });
  }
}

/**
 * GET /api/contacts/enrich
 * Enrichment stats.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const stats = await query(`
      SELECT
        COUNT(*) as total,
        COUNT(*) FILTER (WHERE enrichment_status = 'pending') as pending,
        COUNT(*) FILTER (WHERE enrichment_status = 'enriched') as enriched,
        COUNT(*) FILTER (WHERE enrichment_status = 'failed') as failed,
        COUNT(*) FILTER (WHERE matched_org_id IS NOT NULL) as org_matched,
        (SELECT COUNT(*) FROM contact_work_history cwh JOIN contacts cx ON cx.id = cwh.contact_id WHERE 1=1${orgId ? ' AND cx.org_id = $1' : ''}) as total_work_records,
        (SELECT COUNT(*) FROM contact_education edu JOIN contacts cx ON cx.id = edu.contact_id WHERE 1=1${orgId ? ' AND cx.org_id = $1' : ''}) as total_edu_records,
        (SELECT COUNT(*) FROM contact_groups cg JOIN contacts cx ON cx.id = cg.contact_id WHERE 1=1${orgId ? ' AND cx.org_id = $1' : ''}) as total_groups
      FROM contacts
      ${orgId ? 'WHERE org_id = $1' : ''}
    `, orgId ? [orgId] : []);
    return NextResponse.json({ stats: stats.rows[0] });
  } catch (err) {
    console.error('[api/contacts/enrich] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to get stats' }, { status: 500 });
  }
}