← back to Norma
app/api/contacts/[id]/route.ts
165 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/:id
* Full contact detail with work history, education, groups, and coworkers.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
const { id } = await params;
const contactResult = await query(
`SELECT c.*, o.name as matched_org_name, o.type as matched_org_type
FROM contacts c
LEFT JOIN organizations o ON o.id = c.matched_org_id
WHERE c.id = $1${orgId ? ' AND c.org_id = $2' : ''}`,
orgId ? [id, orgId] : [id],
);
if (contactResult.rowCount === 0) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
}
// Build the company list for coworker lookup from contact record
const contact = contactResult.rows[0];
const contactCompanies: string[] = [];
if (contact.company) contactCompanies.push((contact.company as string).toLowerCase());
const [workResult, eduResult, groupsResult, linksResult] = await Promise.all([
query(
`SELECT cwh.*, o.name as matched_org_name
FROM contact_work_history cwh
LEFT JOIN organizations o ON o.id = cwh.matched_org_id
WHERE cwh.contact_id = $1
ORDER BY cwh.is_current DESC, cwh.start_date DESC NULLS LAST`,
[id],
),
query(
`SELECT * FROM contact_education WHERE contact_id = $1 ORDER BY end_year DESC NULLS LAST`,
[id],
),
query(
`SELECT * FROM contact_groups WHERE contact_id = $1 ORDER BY group_name`,
[id],
),
query(
`SELECT mml.*,
CASE WHEN mml.source_type = 'contact' AND mml.source_id = $1::uuid
THEN mml.target_type ELSE mml.source_type END as linked_type,
CASE WHEN mml.source_type = 'contact' AND mml.source_id = $1::uuid
THEN mml.target_id ELSE mml.source_id END as linked_id
FROM mind_map_links mml
WHERE (mml.source_type = 'contact' AND mml.source_id = $1::uuid)
OR (mml.target_type = 'contact' AND mml.target_id = $1::uuid)
ORDER BY mml.weight DESC`,
[id],
),
]);
// Find coworkers (contacts who share a company) -- uses work history from parallel batch
const companies = (workResult.rows as Array<{ company_name: string }>).map(w => w.company_name).filter(Boolean);
const uniqueCompanies = [...new Set([...contactCompanies, ...companies.map(c => c.toLowerCase())])];
interface CoworkerRow { id: string; first_name: string; last_name: string; company: string; headline: string }
let coworkers: CoworkerRow[] = [];
if (uniqueCompanies.length > 0) {
const cwResult = await query(
`SELECT DISTINCT ON (c.id) c.id, c.first_name, c.last_name, c.company, c.headline
FROM contacts c
LEFT JOIN contact_work_history cwh ON cwh.contact_id = c.id
WHERE c.id != $1
AND (LOWER(c.company) = ANY($2) OR LOWER(cwh.company_name) = ANY($2))${orgId ? ' AND c.org_id = $3' : ''}
LIMIT 20`,
orgId ? [id, uniqueCompanies, orgId] : [id, uniqueCompanies],
);
coworkers = cwResult.rows as CoworkerRow[];
}
return NextResponse.json({
contact: contact,
work_history: workResult.rows,
education: eduResult.rows,
groups: groupsResult.rows,
links: linksResult.rows,
coworkers,
});
} catch (err) {
console.error('[api/contacts/id] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch contact' }, { status: 500 });
}
}
/**
* PUT /api/contacts/:id
* Update contact fields.
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
const { id } = await params;
const data = await request.json();
const allowedFields = [
'first_name', 'last_name', 'email', 'company', 'position',
'linkedin_url', 'headline', 'location', 'summary', 'website',
'skills', 'groups', 'interests', 'tags', 'notes',
];
const updates: string[] = [];
const values: unknown[] = [];
let idx = 1;
for (const [key, value] of Object.entries(data)) {
if (allowedFields.includes(key)) {
updates.push(`${key} = $${idx++}`);
values.push(value);
}
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
}
values.push(id);
if (orgId) {
values.push(orgId);
}
const result = await query(
`UPDATE contacts SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${idx}${orgId ? ` AND org_id = $${idx + 1}` : ''} RETURNING *`,
values,
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
}
// Re-match if company changed
if (data.company) {
await matchContactToOrgs(id);
await createContactLinks(id);
}
return NextResponse.json({ contact: result.rows[0] });
} catch (err) {
console.error('[api/contacts/id] PUT error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 });
}
}