/** * 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) { 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(); // 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: ``, body: ``, scripts: ``, })); }); export const networkModule: RSpaceModule = { id: "network", name: "rNetwork", icon: "\u{1F310}", description: "Community relationship graph visualization with CRM sync", routes, standaloneDomain: "rnetwork.online", };