/** * 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"; import { renderLanding } from "./landing"; // ── CDN scripts for Three.js + 3d-force-graph ── // UMD build bundles Three.js + all transitive deps — no bare-specifier issues. // We also provide an importmap for "three" so folk-graph-viewer can import("three") for custom meshes. const GRAPH3D_HEAD = ` `; const routes = new Hono(); const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online"; const TWENTY_DEFAULT_TOKEN = process.env.TWENTY_API_TOKEN || ""; // Build token map from env vars: TWENTY_TOKEN_COMMONS_HUB -> "commons-hub" const twentyTokens = new Map(); for (const [key, value] of Object.entries(process.env)) { if (key.startsWith("TWENTY_TOKEN_") && value) { const slug = key.replace("TWENTY_TOKEN_", "").toLowerCase().replace(/_/g, "-"); twentyTokens.set(slug, value); } } function getTokenForSpace(space: string): string { return twentyTokens.get(space) || TWENTY_DEFAULT_TOKEN; } // ── GraphQL helper ── async function twentyQuery(query: string, variables?: Record, space?: string) { const token = space ? getTokenForSpace(space) : TWENTY_DEFAULT_TOKEN; if (!token) return null; const res = await fetch(`${TWENTY_API_URL}/graphql`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${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; } // ── Per-space cache layer (60s TTL) ── const graphCaches = new Map(); const CACHE_TTL = 60_000; // ── API: Health ── routes.get("/api/health", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); return c.json({ ok: true, module: "network", space, twentyConfigured: !!token }); }); // ── API: Info ── routes.get("/api/info", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); return c.json({ module: "network", description: "Community relationship graph visualization", entityTypes: ["person", "company", "opportunity"], features: ["force-directed layout", "CRM sync", "real-time collaboration"], space, twentyConfigured: !!token, }); }); // ── API: People ── routes.get("/api/people", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ people(first: 200) { edges { node { id name { firstName lastName } emails { primaryEmail } phones { primaryPhoneNumber } city company { id name } createdAt } } } }`, undefined, dataSpace); if (!data) return c.json({ people: [], error: 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 space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ companies(first: 200) { edges { node { id name domainName { primaryLinkUrl } employees address { addressCity addressCountry } createdAt } } } }`, undefined, dataSpace); if (!data) return c.json({ companies: [], error: 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 }); }); // Helper: resolve the trust/identity space (always per-space, ignoring module's global scoping) function getTrustSpace(c: any): string { return c.req.query("space") || c.req.param("space") || "demo"; } const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; // ── API: Users — EncryptID user directory with trust metadata ── routes.get("/api/users", async (c) => { const space = getTrustSpace(c); try { const res = await fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(space)}`, { signal: AbortSignal.timeout(5000), }); if (!res.ok) return c.json({ users: [], error: "User directory unavailable" }); return c.json(await res.json()); } catch { return c.json({ users: [], error: "EncryptID unreachable" }); } }); // ── API: Trust scores for graph visualization ── routes.get("/api/trust", async (c) => { const space = getTrustSpace(c); const authority = c.req.query("authority") || "gov-ops"; try { const res = await fetch( `${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }, ); if (!res.ok) return c.json({ scores: [], authority, error: "Trust scores unavailable" }); return c.json(await res.json()); } catch { return c.json({ scores: [], authority, error: "EncryptID unreachable" }); } }); // ── API: Delegations for graph edges ── routes.get("/api/delegations", async (c) => { const space = getTrustSpace(c); const authority = c.req.query("authority"); try { const url = new URL(`${ENCRYPTID_URL}/api/delegations/space`); url.searchParams.set("space", space); if (authority) url.searchParams.set("authority", authority); const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (!res.ok) return c.json({ delegations: [], error: "Delegations unavailable" }); return c.json(await res.json()); } catch { return c.json({ delegations: [], error: "EncryptID unreachable" }); } }); // ── API: Graph — transform entities to node/edge format ── routes.get("/api/graph", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); // Check per-space cache (keyed by space + trust params) const includeTrust = c.req.query("trust") === "true"; const authority = c.req.query("authority") || "gov-ops"; const cacheKey = includeTrust ? `${dataSpace}:trust:${authority}` : dataSpace; const cached = graphCaches.get(cacheKey); if (cached && Date.now() - cached.ts < CACHE_TTL) { c.header("Cache-Control", "public, max-age=60"); return c.json(cached.data); } try { // Start with CRM data if available, otherwise demo placeholders const nodes: Array<{ id: string; label: string; type: string; data: unknown }> = []; const edges: Array<{ source: string; target: string; type: string; weight?: number }> = []; const nodeIds = new Set(); let isDemoData = false; if (!token) { isDemoData = true; nodes.push( { 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.push( { 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" }, ); for (const n of nodes) nodeIds.add(n.id); } else { const data = await twentyQuery(`{ people(first: 200) { edges { node { id name { firstName lastName } emails { primaryEmail } city company { id name } } } } companies(first: 200) { edges { node { id name domainName { primaryLinkUrl } employees address { addressCity addressCountry } } } } opportunities(first: 200) { edges { node { id name stage amount { amountMicros currencyCode } company { id name } pointOfContact { id name { firstName lastName } } } } } }`, undefined, dataSpace); if (data) { const d = data as any; 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.emails?.primaryEmail, location: p.city } }); nodeIds.add(p.id); if (p.company?.id) edges.push({ source: p.id, target: p.company.id, type: "works_at" }); } 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, location: co.address?.addressCity } }); nodeIds.add(co.id); } 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" }); } } } // If trust=true, merge EncryptID user nodes + delegation edges // Trust data uses per-space scoping (not module's global scoping) if (includeTrust) { const trustSpace = c.req.param("space") || "demo"; const isAllAuthority = authority === "all"; try { // For "all" mode: fetch delegations without authority filter, scores for all authorities const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`); delegUrl.searchParams.set("space", trustSpace); if (!isAllAuthority) delegUrl.searchParams.set("authority", authority); const fetches: Promise[] = [ fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, { signal: AbortSignal.timeout(5000) }), fetch(delegUrl, { signal: AbortSignal.timeout(5000) }), ]; if (isAllAuthority) { for (const a of ["gov-ops", "fin-ops", "dev-ops"]) { fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(a)}`, { signal: AbortSignal.timeout(5000) })); } } else { fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) })); } const responses = await Promise.all(fetches); const [usersRes, delegRes, ...scoreResponses] = responses; if (usersRes.ok) { const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record }> }; for (const u of userData.users || []) { if (!nodeIds.has(u.did)) { let trustScore = 0; if (isAllAuthority && u.trustScores) { const vals = Object.values(u.trustScores).filter(v => v > 0); trustScore = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; } else { trustScore = u.trustScores?.[authority] ?? 0; } nodes.push({ id: u.did, label: u.displayName || u.username, type: "rspace_user" as any, data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" }, }); nodeIds.add(u.did); } } } // Merge trust scores from all score responses const trustMap = new Map(); for (const scoresRes of scoreResponses) { if (scoresRes.ok) { const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> }; for (const s of scoreData.scores || []) { const existing = trustMap.get(s.did) || 0; trustMap.set(s.did, isAllAuthority ? Math.max(existing, s.totalScore) : s.totalScore); } } } for (const node of nodes) { if (trustMap.has(node.id)) { (node.data as any).trustScore = trustMap.get(node.id); } } // Add delegation edges (with authority tag for per-authority coloring) if (delegRes.ok) { const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> }; for (const d of delegData.delegations || []) { if (nodeIds.has(d.from) && nodeIds.has(d.to)) { edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight, authority: d.authority } as any); } } } } catch (e) { console.error("[Network] Trust enrichment error:", e); } } const result = { nodes, edges, demo: isDemoData && !includeTrust }; graphCaches.set(cacheKey, { 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 }, ]); }); // ── API: Opportunities ── routes.get("/api/opportunities", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ opportunities(first: 200) { edges { node { id name stage amount { amountMicros currencyCode } company { id name } pointOfContact { id name { firstName lastName } } createdAt closeDate } } } }`, undefined, dataSpace); if (!data) return c.json({ opportunities: [], error: token ? "Twenty API error" : "Twenty not configured" }); const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node); c.header("Cache-Control", "public, max-age=60"); return c.json({ opportunities }); }); // ── CRM sub-route — API-driven CRM view ── const CRM_TABS = [ { id: "pipeline", label: "Pipeline" }, { id: "contacts", label: "Contacts" }, { id: "companies", label: "Companies" }, { id: "graph", label: "Graph" }, { id: "delegations", label: "Delegations" }, ] as const; const CRM_TAB_IDS = new Set(CRM_TABS.map(t => t.id)); function renderCrm(space: string, activeTab: string) { return renderShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), body: ``, scripts: ` `, styles: ``, tabs: [...CRM_TABS], activeTab, tabBasePath: `/${space}/rnetwork/crm`, }); } routes.get("/crm", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderCrm(space, "pipeline")); }); // Tab subpath routes: /crm/:tabId routes.get("/crm/:tabId", (c) => { const space = c.req.param("space") || "demo"; const tabId = c.req.param("tabId"); if (!CRM_TAB_IDS.has(tabId as any)) { return c.redirect(`/${space}/rnetwork/crm`, 302); } return c.html(renderCrm(space, tabId)); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); if (view === "app") { return c.redirect(`/${space}/rnetwork/crm`, 301); } return c.html(renderShell({ title: `${space} — Network | rSpace`, moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), head: GRAPH3D_HEAD, body: ` `, scripts: ``, styles: ``, })); }); export const networkModule: RSpaceModule = { id: "rnetwork", name: "rNetwork", icon: "🌐", description: "Community relationship graph visualization with CRM sync", scoping: { defaultScope: 'global', userConfigurable: false }, routes, landingPage: renderLanding, standaloneDomain: "rnetwork.online", externalApp: { url: "https://crm.rspace.online", name: "Twenty CRM" }, feeds: [ { id: "trust-graph", name: "Trust Graph", kind: "trust", description: "People, companies, and relationship edges — the community web of trust", filterable: true, }, { id: "connections", name: "New Connections", kind: "trust", description: "Recently added people and relationship links", }, ], acceptsFeeds: ["data", "trust", "governance"], outputPaths: [ { path: "connections", name: "Connections", icon: "🤝", description: "Community member connections" }, { path: "groups", name: "Groups", icon: "👥", description: "Relationship groups and circles" }, ], subPageInfos: [ { path: "crm", title: "Community CRM", icon: "📇", tagline: "rNetwork Tool", description: "Full-featured CRM for community relationship management — contacts, companies, deals, and pipelines powered by Twenty CRM.", features: [ { icon: "👤", title: "Contact Management", text: "Track people, organizations, and their roles in your community." }, { icon: "🔗", title: "Relationship Graph", text: "Visualize how members connect and identify key connectors." }, { icon: "📊", title: "Pipeline Tracking", text: "Manage opportunities and partnerships through customizable stages." }, ], }, ], };