← back to Norma
app/api/contacts/route.ts
145 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';
/**
* GET /api/contacts
* List/search contacts with filtering and pagination.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q');
const company = searchParams.get('company');
const enrichmentStatus = searchParams.get('enrichment_status');
const hasOrgMatch = searchParams.get('has_org_match');
const tag = searchParams.get('tag');
const sort = searchParams.get('sort') || 'name';
const sortDir = searchParams.get('sort_dir') === 'desc' ? 'DESC' : 'ASC';
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (orgId) {
conditions.push(`c.org_id = $${idx}`);
params.push(orgId);
idx++;
}
if (q) {
conditions.push(`(to_tsvector('english', first_name || ' ' || last_name) @@ plainto_tsquery($${idx}) OR LOWER(company) LIKE '%' || LOWER($${idx}) || '%' OR LOWER(headline) LIKE '%' || LOWER($${idx}) || '%')`);
params.push(q);
idx++;
}
if (company) {
conditions.push(`LOWER(company) = LOWER($${idx})`);
params.push(company);
idx++;
}
if (enrichmentStatus) {
conditions.push(`enrichment_status = $${idx}`);
params.push(enrichmentStatus);
idx++;
}
if (hasOrgMatch === 'true') {
conditions.push('matched_org_id IS NOT NULL');
} else if (hasOrgMatch === 'false') {
conditions.push('matched_org_id IS NULL');
}
if (tag) {
conditions.push(`$${idx} = ANY(tags)`);
params.push(tag);
idx++;
}
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const sortMap: Record<string, string> = {
name: 'first_name', company: 'company', connected_on: 'connected_on',
relevance_score: 'relevance_score', created_at: 'created_at',
};
const orderCol = sortMap[sort] || 'first_name';
const [dataResult, countResult] = await Promise.all([
query(
`SELECT c.*, o.name as matched_org_name
FROM contacts c
LEFT JOIN organizations o ON o.id = c.matched_org_id
${where}
ORDER BY ${orderCol} ${sortDir} NULLS LAST
LIMIT $${idx} OFFSET $${idx + 1}`,
[...params, limit, offset],
),
query(`SELECT COUNT(*) FROM contacts c ${where}`, params),
]);
return NextResponse.json({
contacts: dataResult.rows,
total: parseInt(countResult.rows[0].count),
limit,
offset,
});
} catch (err) {
console.error('[api/contacts] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch contacts' }, { status: 500 });
}
}
/**
* POST /api/contacts
* Create a single contact.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const data = await request.json();
if (!data.first_name || !data.last_name) {
return NextResponse.json({ error: 'first_name and last_name are required' }, { status: 400 });
}
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const result = await query(
`INSERT INTO contacts (first_name, last_name, email, company, position, linkedin_url, headline, location, source, org_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (LOWER(first_name), LOWER(last_name), LOWER(COALESCE(email, ''))) DO UPDATE
SET company = COALESCE(EXCLUDED.company, contacts.company),
position = COALESCE(EXCLUDED.position, contacts.position),
linkedin_url = COALESCE(EXCLUDED.linkedin_url, contacts.linkedin_url),
org_id = COALESCE(EXCLUDED.org_id, contacts.org_id),
updated_at = NOW()
RETURNING *`,
[
data.first_name.trim(), data.last_name.trim(),
data.email || null, data.company || null, data.position || null,
data.linkedin_url || null, data.headline || null, data.location || null,
data.source || 'manual', orgId,
],
);
const contact = result.rows[0];
// Auto-match org and create links
await matchContactToOrgs(contact.id);
await createContactLinks(contact.id);
return NextResponse.json({ contact }, { status: 201 });
} catch (err) {
console.error('[api/contacts] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 });
}
}