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