rspace-online/modules/rnetwork/mod.ts

677 lines
28 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";
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 = `<script src="https://cdn.jsdelivr.net/npm/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js"
}
}
</script>`;
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<string, string>();
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<string, unknown>, 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<string, { data: unknown; ts: number }>();
const CACHE_TTL = 60_000;
// ── API: Health ──
routes.get("/api/health", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || 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") || 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") || 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") || 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") || 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 trustSpace = c.req.param("space") || "demo";
const cacheKey = includeTrust ? `${dataSpace}:${trustSpace}:trust:${authority}` : `${dataSpace}:${trustSpace}`;
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<string>();
let isDemoData = false;
if (!token) {
isDemoData = true;
// ── Demo: 48 members with delegation-based trust flows ──
// Members: id, name, role, delegatedWeight per authority (gov, fin, dev)
const members: Array<[string, string, string, number, number, number]> = [
// Stewards — top-level, high trust across boards
["m01", "Alice Chen", "steward", 0.95, 0.50, 0.40],
["m02", "Bob Martinez", "steward", 0.45, 0.90, 0.35],
["m03", "Carol Okafor", "steward", 0.40, 0.35, 0.95],
// Council — secondary hubs
["m04", "David Kim", "council", 0.70, 0.40, 0.35],
["m05", "Eve Nakamura", "council", 0.35, 0.30, 0.75],
["m06", "Frank Osei", "council", 0.30, 0.80, 0.25],
["m25", "Anika Bergström", "council", 0.65, 0.30, 0.25],
["m26", "Rafael Oliveira", "council", 0.25, 0.75, 0.30],
["m27", "Chen Wei", "council", 0.30, 0.25, 0.70],
// Contributors — mid-tier, specialize in one vertical
["m07", "Grace Liu", "contributor", 0.50, 0.20, 0.65],
["m08", "Hassan Patel", "contributor", 0.20, 0.60, 0.30],
["m09", "Ingrid Svensson", "contributor", 0.55, 0.25, 0.30],
["m10", "Jorge Reyes", "contributor", 0.20, 0.55, 0.25],
["m11", "Kaia Tanaka", "contributor", 0.15, 0.20, 0.65],
["m12", "Leo Adeyemi", "contributor", 0.20, 0.15, 0.60],
["m28", "Fatima Al-Hassan", "contributor", 0.50, 0.20, 0.15],
["m29", "Marcus Johnson", "contributor", 0.15, 0.55, 0.20],
["m30", "Yuna Park", "contributor", 0.20, 0.15, 0.55],
["m31", "Dmitri Volkov", "contributor", 0.45, 0.25, 0.20],
["m32", "Amara Diallo", "contributor", 0.15, 0.50, 0.25],
["m33", "Liam O'Connor", "contributor", 0.20, 0.20, 0.50],
["m34", "Zara Hussain", "contributor", 0.45, 0.15, 0.20],
["m35", "Tomás Herrera", "contributor", 0.20, 0.50, 0.15],
["m36", "Sakura Ito", "contributor", 0.15, 0.15, 0.50],
// Members — base layer, delegate upward
["m13", "Maya Johansson", "member", 0.15, 0.20, 0.30],
["m14", "Nia Mensah", "member", 0.10, 0.40, 0.15],
["m15", "Omar Farouk", "member", 0.30, 0.15, 0.20],
["m16", "Priya Sharma", "member", 0.10, 0.30, 0.20],
["m17", "Quinn O'Brien", "member", 0.20, 0.10, 0.15],
["m18", "Rosa Gutierrez", "member", 0.15, 0.20, 0.10],
["m19", "Sam Achebe", "member", 0.10, 0.10, 0.35],
["m20", "Tara Singh", "member", 0.05, 0.25, 0.10],
["m21", "Uri Goldberg", "member", 0.15, 0.08, 0.12],
["m22", "Valentina Costa", "member", 0.08, 0.15, 0.10],
["m23", "Wei Zhang", "member", 0.20, 0.10, 0.08],
["m24", "Yuki Mori", "member", 0.06, 0.05, 0.18],
["m37", "Eleni Papadopoulos", "member", 0.25, 0.10, 0.10],
["m38", "Kwame Asante", "member", 0.10, 0.30, 0.08],
["m39", "Astrid Lindgren", "member", 0.08, 0.10, 0.25],
["m40", "Ravi Kapoor", "member", 0.22, 0.12, 0.10],
["m41", "Nadia Petrov", "member", 0.10, 0.28, 0.12],
["m42", "Javier Morales", "member", 0.12, 0.08, 0.22],
["m43", "Asha Nair", "member", 0.20, 0.10, 0.08],
["m44", "Pierre Dubois", "member", 0.08, 0.22, 0.10],
["m45", "Hana Novak", "member", 0.10, 0.08, 0.20],
["m46", "Kofi Mensah", "member", 0.18, 0.12, 0.06],
["m47", "Isabella Romano", "member", 0.06, 0.20, 0.10],
["m48", "Lars Eriksson", "member", 0.10, 0.06, 0.18],
];
for (const [id, name, role, govW, finW, devW] of members) {
const avgTrust = (govW + finW + devW) / 3;
nodes.push({
id, label: name, type: "rspace_user" as any,
data: {
role,
trustScore: Math.round(avgTrust * 100) / 100,
delegatedWeight: Math.round(Math.max(govW, finW, devW) * 100) / 100,
trustScores: { "gov-ops": govW, "fin-ops": finW, "dev-ops": devW },
},
});
}
// ── Delegation edges: who delegates to whom, per authority ──
// Gov-ops delegations — Alice (m01) is top target, m04/m25 are secondary hubs
const govDelegations: Array<[string, string, number]> = [
// Council → stewards
["m04", "m01", 0.7], ["m05", "m01", 0.5], ["m06", "m01", 0.4],
["m25", "m01", 0.8], ["m26", "m01", 0.3], ["m27", "m01", 0.3],
// Council cross-delegation
["m06", "m04", 0.3], ["m26", "m25", 0.4],
// Contributors → council/stewards
["m07", "m04", 0.6], ["m08", "m25", 0.4], ["m09", "m01", 0.6],
["m10", "m04", 0.3], ["m11", "m25", 0.3], ["m12", "m04", 0.4],
["m28", "m01", 0.5], ["m29", "m25", 0.3], ["m30", "m04", 0.3],
["m31", "m01", 0.5], ["m32", "m25", 0.3], ["m33", "m04", 0.3],
["m34", "m25", 0.5], ["m35", "m04", 0.3], ["m36", "m25", 0.3],
// Members → contributors/council
["m13", "m09", 0.3], ["m14", "m28", 0.3], ["m15", "m01", 0.4],
["m16", "m04", 0.2], ["m17", "m31", 0.3], ["m18", "m09", 0.2],
["m19", "m34", 0.2], ["m20", "m25", 0.2], ["m21", "m28", 0.3],
["m22", "m04", 0.2], ["m23", "m01", 0.4], ["m24", "m31", 0.1],
["m37", "m01", 0.4], ["m38", "m25", 0.2], ["m39", "m04", 0.2],
["m40", "m09", 0.3], ["m41", "m28", 0.2], ["m42", "m25", 0.2],
["m43", "m01", 0.3], ["m44", "m04", 0.2], ["m45", "m34", 0.2],
["m46", "m25", 0.3], ["m47", "m31", 0.1], ["m48", "m04", 0.2],
];
// Fin-ops delegations — Bob (m02) is top target, m06/m26 are secondary hubs
const finDelegations: Array<[string, string, number]> = [
// Council → stewards
["m04", "m02", 0.6], ["m05", "m06", 0.5], ["m06", "m02", 0.7],
["m25", "m02", 0.3], ["m26", "m02", 0.8], ["m27", "m06", 0.3],
// Council cross-delegation
["m04", "m06", 0.3], ["m25", "m26", 0.3],
// Contributors → council/stewards
["m07", "m06", 0.4], ["m08", "m02", 0.7], ["m09", "m26", 0.3],
["m10", "m02", 0.6], ["m11", "m06", 0.4], ["m12", "m26", 0.3],
["m28", "m06", 0.3], ["m29", "m02", 0.6], ["m30", "m26", 0.3],
["m31", "m06", 0.3], ["m32", "m02", 0.5], ["m33", "m26", 0.3],
["m34", "m06", 0.2], ["m35", "m02", 0.6], ["m36", "m26", 0.3],
// Members → contributors/council
["m13", "m06", 0.3], ["m14", "m02", 0.5], ["m15", "m26", 0.2],
["m16", "m08", 0.4], ["m17", "m06", 0.2], ["m18", "m02", 0.3],
["m19", "m10", 0.2], ["m20", "m26", 0.4], ["m21", "m06", 0.1],
["m22", "m02", 0.3], ["m23", "m29", 0.2], ["m24", "m32", 0.1],
["m37", "m26", 0.2], ["m38", "m02", 0.4], ["m39", "m06", 0.2],
["m40", "m29", 0.2], ["m41", "m02", 0.4], ["m42", "m26", 0.1],
["m43", "m06", 0.2], ["m44", "m02", 0.3], ["m45", "m32", 0.1],
["m46", "m26", 0.2], ["m47", "m02", 0.3], ["m48", "m35", 0.1],
];
// Dev-ops delegations — Carol (m03) is top target, m05/m27 are secondary hubs
const devDelegations: Array<[string, string, number]> = [
// Council → stewards
["m04", "m03", 0.4], ["m05", "m03", 0.7], ["m06", "m03", 0.3],
["m25", "m03", 0.3], ["m26", "m27", 0.3], ["m27", "m03", 0.7],
// Council cross-delegation
["m04", "m05", 0.3], ["m25", "m27", 0.3],
// Contributors → council/stewards
["m07", "m03", 0.7], ["m08", "m05", 0.4], ["m09", "m27", 0.4],
["m10", "m05", 0.4], ["m11", "m03", 0.7], ["m12", "m27", 0.6],
["m28", "m05", 0.3], ["m29", "m27", 0.3], ["m30", "m03", 0.6],
["m31", "m05", 0.3], ["m32", "m27", 0.3], ["m33", "m03", 0.5],
["m34", "m27", 0.3], ["m35", "m05", 0.2], ["m36", "m03", 0.6],
// Members → contributors/council
["m13", "m07", 0.4], ["m14", "m05", 0.2], ["m15", "m27", 0.3],
["m16", "m11", 0.3], ["m17", "m07", 0.2], ["m18", "m30", 0.2],
["m19", "m03", 0.5], ["m20", "m27", 0.2], ["m21", "m11", 0.2],
["m22", "m05", 0.2], ["m23", "m33", 0.1], ["m24", "m03", 0.3],
["m37", "m27", 0.2], ["m38", "m05", 0.1], ["m39", "m03", 0.3],
["m40", "m30", 0.2], ["m41", "m27", 0.2], ["m42", "m03", 0.3],
["m43", "m36", 0.1], ["m44", "m05", 0.2], ["m45", "m03", 0.3],
["m46", "m33", 0.1], ["m47", "m27", 0.2], ["m48", "m03", 0.3],
];
for (const [from, to, weight] of govDelegations) {
edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "gov-ops" } as any);
}
for (const [from, to, weight] of finDelegations) {
edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "fin-ops" } as any);
}
for (const [from, to, weight] of devDelegations) {
edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "dev-ops" } as any);
}
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" });
}
}
}
// Always fetch EncryptID members for space context
// Delegations + trust scores remain gated behind trust=true
const isAllAuthority = authority === "all";
type UserEntry = { did: string; username: string; displayName: string | null; email?: string; trustScores: Record<string, number> };
let spaceUsers: UserEntry[] = [];
try {
const usersRes = await fetch(
`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`,
{ signal: AbortSignal.timeout(5000) },
);
if (usersRes.ok) {
const userData = await usersRes.json() as { users: UserEntry[] };
spaceUsers = userData.users || [];
}
} catch (e) { console.error("[Network] Member fetch error:", e); }
if (spaceUsers.length > 0) {
// Add synthetic space hub node
const spaceHubId = `space:${trustSpace}`;
if (!nodeIds.has(spaceHubId)) {
nodes.push({ id: spaceHubId, label: trustSpace, type: "space" as any, data: {} });
nodeIds.add(spaceHubId);
}
// Collect CRM people for name/email matching
const crmPeople = nodes.filter(n => n.type === "person");
for (const u of spaceUsers) {
if (!nodeIds.has(u.did)) {
let trustScore = 0;
if (includeTrust) {
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);
}
// member_of edge: member → space hub
edges.push({ source: u.did, target: spaceHubId, type: "member_of" });
// Auto-match member to CRM person by name or email
const uName = (u.displayName || u.username || "").toLowerCase().trim();
const uFirst = uName.split(/\s+/)[0];
for (const crmNode of crmPeople) {
const crmName = crmNode.label.toLowerCase().trim();
const crmFirst = crmName.split(/\s+/)[0];
const nameMatch = uName && crmName && (uName === crmName || (uFirst.length > 2 && uFirst === crmFirst));
const emailMatch = u.email && (crmNode.data as any)?.email && u.email.toLowerCase() === ((crmNode.data as any).email as string).toLowerCase();
if (nameMatch || emailMatch) {
edges.push({ source: u.did, target: crmNode.id, type: "member_is" });
}
}
}
}
// If trust=true, also fetch delegation edges + trust scores
if (includeTrust) {
try {
const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`);
delegUrl.searchParams.set("space", trustSpace);
if (!isAllAuthority) delegUrl.searchParams.set("authority", authority);
const fetches: Promise<Response>[] = [
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 [delegRes, ...scoreResponses] = responses;
// Merge trust scores
const trustMap = new Map<string, number>();
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
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") || 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: `<folk-crm-view space="${space}"></folk-crm-view>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
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: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="/${space}/rnetwork/crm" class="rapp-nav__btn--app-toggle">Open CRM</a></div>
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
}));
});
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." },
],
},
],
};