239 lines
7.0 KiB
TypeScript
239 lines
7.0 KiB
TypeScript
/**
|
|
* Network module — community relationship graph viewer.
|
|
*
|
|
* Visualizes CRM data as interactive force-directed graphs.
|
|
* Nodes: people, companies, opportunities. Edges: relationships.
|
|
* Syncs from Twenty CRM via GraphQL API proxy.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
|
|
const routes = new Hono();
|
|
|
|
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://rnetwork.online";
|
|
const TWENTY_API_TOKEN = process.env.TWENTY_API_TOKEN || "";
|
|
|
|
// ── GraphQL helper ──
|
|
async function twentyQuery(query: string, variables?: Record<string, unknown>) {
|
|
if (!TWENTY_API_TOKEN) return null;
|
|
const res = await fetch(`${TWENTY_API_URL}/api`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${TWENTY_API_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!res.ok) return null;
|
|
const json = await res.json() as { data?: unknown };
|
|
return json.data ?? null;
|
|
}
|
|
|
|
// ── Cache layer (60s TTL) ──
|
|
let graphCache: { data: unknown; ts: number } | null = null;
|
|
const CACHE_TTL = 60_000;
|
|
|
|
// ── API: Health ──
|
|
routes.get("/api/health", (c) => {
|
|
return c.json({ ok: true, module: "network", twentyConfigured: !!TWENTY_API_TOKEN });
|
|
});
|
|
|
|
// ── API: Info ──
|
|
routes.get("/api/info", (c) => {
|
|
return c.json({
|
|
module: "network",
|
|
description: "Community relationship graph visualization",
|
|
entityTypes: ["person", "company", "opportunity"],
|
|
features: ["force-directed layout", "CRM sync", "real-time collaboration"],
|
|
twentyConfigured: !!TWENTY_API_TOKEN,
|
|
});
|
|
});
|
|
|
|
// ── API: People ──
|
|
routes.get("/api/people", async (c) => {
|
|
const data = await twentyQuery(`{
|
|
people(first: 200) {
|
|
edges {
|
|
node {
|
|
id
|
|
name { firstName lastName }
|
|
email { primaryEmail }
|
|
phone { primaryPhoneNumber }
|
|
city
|
|
company { id name { firstName lastName } }
|
|
createdAt
|
|
}
|
|
}
|
|
}
|
|
}`);
|
|
if (!data) return c.json({ people: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
|
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
|
|
c.header("Cache-Control", "public, max-age=60");
|
|
return c.json({ people });
|
|
});
|
|
|
|
// ── API: Companies ──
|
|
routes.get("/api/companies", async (c) => {
|
|
const data = await twentyQuery(`{
|
|
companies(first: 200) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
domainName { primaryLinkUrl }
|
|
employees
|
|
address { addressCity addressCountry }
|
|
createdAt
|
|
}
|
|
}
|
|
}
|
|
}`);
|
|
if (!data) return c.json({ companies: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
|
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
|
|
c.header("Cache-Control", "public, max-age=60");
|
|
return c.json({ companies });
|
|
});
|
|
|
|
// ── API: Graph — transform entities to node/edge format ──
|
|
routes.get("/api/graph", async (c) => {
|
|
// Check cache
|
|
if (graphCache && Date.now() - graphCache.ts < CACHE_TTL) {
|
|
c.header("Cache-Control", "public, max-age=60");
|
|
return c.json(graphCache.data);
|
|
}
|
|
|
|
if (!TWENTY_API_TOKEN) {
|
|
return c.json({
|
|
nodes: [
|
|
{ id: "demo-1", label: "Alice", type: "person", data: {} },
|
|
{ id: "demo-2", label: "Bob", type: "person", data: {} },
|
|
{ id: "demo-3", label: "Acme Corp", type: "company", data: {} },
|
|
],
|
|
edges: [
|
|
{ source: "demo-1", target: "demo-3", type: "works_at" },
|
|
{ source: "demo-2", target: "demo-3", type: "works_at" },
|
|
{ source: "demo-1", target: "demo-2", type: "contact_of" },
|
|
],
|
|
demo: true,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const data = await twentyQuery(`{
|
|
people(first: 200) {
|
|
edges {
|
|
node {
|
|
id
|
|
name { firstName lastName }
|
|
email { primaryEmail }
|
|
company { id name { firstName lastName } }
|
|
}
|
|
}
|
|
}
|
|
companies(first: 200) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
domainName { primaryLinkUrl }
|
|
employees
|
|
}
|
|
}
|
|
}
|
|
opportunities(first: 200) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
stage
|
|
amount { amountMicros currencyCode }
|
|
company { id name }
|
|
pointOfContact { id name { firstName lastName } }
|
|
}
|
|
}
|
|
}
|
|
}`);
|
|
|
|
if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" });
|
|
|
|
const d = data as any;
|
|
const nodes: Array<{ id: string; label: string; type: string; data: unknown }> = [];
|
|
const edges: Array<{ source: string; target: string; type: string }> = [];
|
|
const nodeIds = new Set<string>();
|
|
|
|
// People → nodes
|
|
for (const { node: p } of d.people?.edges || []) {
|
|
const label = [p.name?.firstName, p.name?.lastName].filter(Boolean).join(" ") || "Unknown";
|
|
nodes.push({ id: p.id, label, type: "person", data: { email: p.email?.primaryEmail } });
|
|
nodeIds.add(p.id);
|
|
|
|
// Person → Company edge
|
|
if (p.company?.id) {
|
|
edges.push({ source: p.id, target: p.company.id, type: "works_at" });
|
|
}
|
|
}
|
|
|
|
// Companies → nodes
|
|
for (const { node: co } of d.companies?.edges || []) {
|
|
nodes.push({ id: co.id, label: co.name || "Unknown", type: "company", data: { domain: co.domainName?.primaryLinkUrl, employees: co.employees } });
|
|
nodeIds.add(co.id);
|
|
}
|
|
|
|
// Opportunities → nodes + edges
|
|
for (const { node: opp } of d.opportunities?.edges || []) {
|
|
nodes.push({ id: opp.id, label: opp.name || "Opportunity", type: "opportunity", data: { stage: opp.stage, amount: opp.amount } });
|
|
nodeIds.add(opp.id);
|
|
|
|
if (opp.company?.id && nodeIds.has(opp.company.id)) {
|
|
edges.push({ source: opp.id, target: opp.company.id, type: "involves" });
|
|
}
|
|
if (opp.pointOfContact?.id && nodeIds.has(opp.pointOfContact.id)) {
|
|
edges.push({ source: opp.pointOfContact.id, target: opp.id, type: "involved_in" });
|
|
}
|
|
}
|
|
|
|
const result = { nodes, edges, demo: false };
|
|
graphCache = { data: result, ts: Date.now() };
|
|
c.header("Cache-Control", "public, max-age=60");
|
|
return c.json(result);
|
|
} catch (e) {
|
|
console.error("[Network] Graph fetch error:", e);
|
|
return c.json({ nodes: [], edges: [], error: "Graph fetch failed" });
|
|
}
|
|
});
|
|
|
|
// ── API: Workspaces ──
|
|
routes.get("/api/workspaces", (c) => {
|
|
return c.json([
|
|
{ slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 },
|
|
]);
|
|
});
|
|
|
|
// ── Page route ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${space} — Network | rSpace`,
|
|
moduleId: "network",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
|
|
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
|
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const networkModule: RSpaceModule = {
|
|
id: "network",
|
|
name: "rNetwork",
|
|
icon: "\u{1F310}",
|
|
description: "Community relationship graph visualization with CRM sync",
|
|
routes,
|
|
standaloneDomain: "rnetwork.online",
|
|
};
|