← back to Norma
app/api/graph/journalist/[id]/route.ts
311 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
// ─── Types ───────────────────────────────────────────────────────────────────
interface JournalistGraphNode {
id: string;
type: 'journalist' | 'article' | 'topic' | 'organization' | 'contact' | 'category';
label: string;
data: Record<string, unknown>;
}
interface JournalistGraphEdge {
source: string;
target: string;
type: string;
weight: number;
}
// ─── GET /api/graph/journalist/[id] ──────────────────────────────────────────
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { id } = await params;
// Fetch journalist
const jResult = await query(
'SELECT * FROM journalists WHERE id = $1',
[id],
);
if (jResult.rowCount === 0) {
return NextResponse.json({ error: 'Journalist not found' }, { status: 404 });
}
const journalist = jResult.rows[0];
const nodes: JournalistGraphNode[] = [];
const edges: JournalistGraphEdge[] = [];
const nodeIdSet = new Set<string>();
// Center node: journalist
const jNodeId = `jrn-${id}`;
nodes.push({
id: jNodeId,
type: 'journalist',
label: journalist.name as string,
data: {
outlet: journalist.outlet,
beat: journalist.beat,
email: journalist.email,
twitter: journalist.twitter_handle,
},
});
nodeIdSet.add(jNodeId);
// Articles (limit 30)
const articles = await query(
`SELECT id, title, url, outlet, published_date, summary, topics, sentiment,
mentioned_people, mentioned_orgs, word_count
FROM journalist_articles
WHERE journalist_id = $1
ORDER BY published_date DESC NULLS LAST
LIMIT 30`,
[id],
);
for (const a of articles.rows) {
const artNodeId = `art-${a.id}`;
nodes.push({
id: artNodeId,
type: 'article',
label: (a.title as string).slice(0, 60),
data: {
title: a.title,
url: a.url,
outlet: a.outlet,
published_date: a.published_date,
summary: a.summary,
sentiment: a.sentiment,
word_count: a.word_count,
},
});
nodeIdSet.add(artNodeId);
edges.push({ source: jNodeId, target: artNodeId, type: 'wrote', weight: 1.0 });
}
// Topics: aggregate from articles, match against trending_topics
const topicAgg = await query(
`SELECT unnest(topics) as topic, COUNT(*) as cnt
FROM journalist_articles WHERE journalist_id = $1
GROUP BY topic ORDER BY cnt DESC LIMIT 20`,
[id],
);
for (const t of topicAgg.rows) {
const topicName = t.topic as string;
const cnt = Number(t.cnt);
// Try to find real topic in trending_topics
const topicMatch = await query(
`SELECT id, topic, score FROM trending_topics
WHERE LOWER(topic) LIKE '%' || $1 || '%'
OR $1 LIKE '%' || LOWER(topic) || '%'
ORDER BY score DESC LIMIT 1`,
[topicName.toLowerCase()],
);
let topicNodeId: string;
if (topicMatch.rowCount && topicMatch.rowCount > 0) {
topicNodeId = `topic-${topicMatch.rows[0].id}`;
if (!nodeIdSet.has(topicNodeId)) {
nodes.push({
id: topicNodeId,
type: 'topic',
label: topicMatch.rows[0].topic as string,
data: { score: topicMatch.rows[0].score, mention_count: cnt },
});
nodeIdSet.add(topicNodeId);
}
} else {
topicNodeId = `vtopic-${topicName.toLowerCase().replace(/\s+/g, '-')}`;
if (!nodeIdSet.has(topicNodeId)) {
nodes.push({
id: topicNodeId,
type: 'topic',
label: topicName,
data: { score: 0, mention_count: cnt, virtual: true },
});
nodeIdSet.add(topicNodeId);
}
}
const weight = Math.min(1.0, 0.3 + cnt * 0.15);
edges.push({ source: jNodeId, target: topicNodeId, type: 'covers_topic', weight });
// Connect articles to their topics
for (const a of articles.rows) {
const artTopics = (a.topics as string[]) || [];
if (artTopics.some(at => at.toLowerCase() === topicName.toLowerCase())) {
edges.push({ source: `art-${a.id}`, target: topicNodeId, type: 'about_topic', weight: 0.4 });
}
}
}
// Organizations from journalist_orgs, match against organizations table
const orgs = await query(
'SELECT org_name, org_type, relationship, strength FROM journalist_orgs WHERE journalist_id = $1',
[id],
);
for (const org of orgs.rows) {
const orgName = org.org_name as string;
const strength = Number(org.strength);
const orgMatch = await query(
`SELECT id, name, type FROM organizations
WHERE LOWER(name) = LOWER($1)
OR LOWER(name) LIKE '%' || LOWER($1) || '%'
OR LOWER($1) LIKE '%' || LOWER(name) || '%'
ORDER BY LENGTH(name) ASC LIMIT 1`,
[orgName],
);
let orgNodeId: string;
if (orgMatch.rowCount && orgMatch.rowCount > 0) {
orgNodeId = `org-${orgMatch.rows[0].id}`;
if (!nodeIdSet.has(orgNodeId)) {
nodes.push({
id: orgNodeId,
type: 'organization',
label: orgMatch.rows[0].name as string,
data: { type: orgMatch.rows[0].type, strength, relationship: org.relationship },
});
nodeIdSet.add(orgNodeId);
}
} else {
orgNodeId = `vorg-${orgName.toLowerCase().replace(/\s+/g, '-')}`;
if (!nodeIdSet.has(orgNodeId)) {
nodes.push({
id: orgNodeId,
type: 'organization',
label: orgName,
data: { strength, relationship: org.relationship, virtual: true },
});
nodeIdSet.add(orgNodeId);
}
}
const weight = 0.3 + (strength - 1) * 0.175;
edges.push({ source: jNodeId, target: orgNodeId, type: 'covers_org', weight });
}
// Mentioned people: match against contacts
const peopleAgg = await query(
`SELECT unnest(mentioned_people) as person, COUNT(*) as cnt
FROM journalist_articles WHERE journalist_id = $1
GROUP BY person ORDER BY cnt DESC LIMIT 20`,
[id],
);
for (const p of peopleAgg.rows) {
const personName = (p.person as string).trim();
if (personName.length < 3) continue;
const parts = personName.split(/\s+/);
const contactMatch = parts.length >= 2
? await query(
`SELECT id, first_name, last_name, company, headline FROM contacts
WHERE LOWER(first_name || ' ' || last_name) = LOWER($1)
OR (LOWER(first_name) = LOWER($2) AND LOWER(last_name) = LOWER($3))
LIMIT 1`,
[personName, parts[0], parts[parts.length - 1]],
)
: { rowCount: 0, rows: [] };
let personNodeId: string;
if (contactMatch.rowCount && contactMatch.rowCount > 0) {
const ct = contactMatch.rows[0];
personNodeId = `con-${ct.id}`;
if (!nodeIdSet.has(personNodeId)) {
nodes.push({
id: personNodeId,
type: 'contact',
label: `${ct.first_name} ${ct.last_name}`,
data: { company: ct.company, headline: ct.headline },
});
nodeIdSet.add(personNodeId);
}
} else {
personNodeId = `person-${personName.toLowerCase().replace(/\s+/g, '-')}`;
if (!nodeIdSet.has(personNodeId)) {
nodes.push({
id: personNodeId,
type: 'contact',
label: personName,
data: { virtual: true, mention_count: p.cnt },
});
nodeIdSet.add(personNodeId);
}
}
edges.push({ source: jNodeId, target: personNodeId, type: 'mentions_person', weight: 0.5 });
// Connect specific articles to the people they mention
for (const a of articles.rows) {
const mentioned = (a.mentioned_people as string[]) || [];
if (mentioned.some(m => m.toLowerCase().includes(personName.toLowerCase()))) {
edges.push({ source: `art-${a.id}`, target: personNodeId, type: 'mentions', weight: 0.3 });
}
}
}
// Existing mind_map_links for this journalist
const existingLinks = await query(
`SELECT source_type, source_id, target_type, target_id, link_type, COALESCE(weight, 0.5) AS weight
FROM mind_map_links
WHERE (source_type = 'journalist' AND source_id = $1::uuid)
OR (target_type = 'journalist' AND target_id = $1::uuid)`,
[id],
);
for (const link of existingLinks.rows) {
const srcId = link.source_type === 'journalist'
? jNodeId
: `${link.source_type}-${link.source_id}`;
const tgtId = link.target_type === 'journalist'
? jNodeId
: `${link.target_type}-${link.target_id}`;
if (nodeIdSet.has(srcId) && nodeIdSet.has(tgtId)) {
edges.push({
source: srcId,
target: tgtId,
type: link.link_type as string,
weight: Number(link.weight) || 0.5,
});
}
}
// Deduplicate edges
const edgeMap = new Map<string, JournalistGraphEdge>();
for (const e of edges) {
const key = `${e.source}|${e.target}|${e.type}`;
const existing = edgeMap.get(key);
if (!existing || e.weight > existing.weight) {
edgeMap.set(key, e);
}
}
return NextResponse.json({
journalist,
nodes,
edges: Array.from(edgeMap.values()),
stats: {
articles: articles.rowCount,
topics: topicAgg.rowCount,
orgs: orgs.rowCount,
people: peopleAgg.rowCount,
},
});
} catch (err) {
console.error('[api/graph/journalist] Error:', (err as Error).message);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}