786 lines
33 KiB
TypeScript
786 lines
33 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://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/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 && process.env.TWENTY_API_TOKEN !== "disabled") ? 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;
|
|
|
|
// Demo members: always show in "demo" space, or when no CRM token
|
|
const useDemoMembers = !token || space === "demo";
|
|
if (useDemoMembers) {
|
|
isDemoData = true;
|
|
|
|
// ── Demo: 150 members with delegation-based trust flows ──
|
|
// Deterministic PRNG for reproducible delegation edges
|
|
function mulberry32(seed: number) {
|
|
return () => {
|
|
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
|
|
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// Members: [id, name, permissionLevel, govW, finW, devW]
|
|
const members: Array<[string, string, string, number, number, number]> = [
|
|
// ── Admins (m001-m015): high trust weights ──
|
|
["m001", "Alice Chen", "admin", 0.95, 0.50, 0.40],
|
|
["m002", "Bob Martinez", "admin", 0.45, 0.90, 0.35],
|
|
["m003", "Carol Okafor", "admin", 0.40, 0.35, 0.95],
|
|
["m004", "David Kim", "admin", 0.80, 0.55, 0.45],
|
|
["m005", "Eve Nakamura", "admin", 0.50, 0.40, 0.85],
|
|
["m006", "Frank Osei", "admin", 0.35, 0.85, 0.30],
|
|
["m007", "Grace Liu", "admin", 0.70, 0.30, 0.65],
|
|
["m008", "Hassan Patel", "admin", 0.30, 0.75, 0.40],
|
|
["m009", "Ingrid Svensson", "admin", 0.60, 0.45, 0.50],
|
|
["m010", "Jorge Reyes", "admin", 0.40, 0.60, 0.55],
|
|
["m011", "Kaia Tanaka", "admin", 0.55, 0.35, 0.80],
|
|
["m012", "Leo Adeyemi", "admin", 0.45, 0.50, 0.70],
|
|
["m013", "Anika Bergström", "admin", 0.75, 0.40, 0.35],
|
|
["m014", "Rafael Oliveira", "admin", 0.35, 0.80, 0.45],
|
|
["m015", "Chen Wei", "admin", 0.40, 0.35, 0.75],
|
|
// ── Members (m016-m050): medium weights ──
|
|
["m016", "Fatima Al-Hassan", "member", 0.50, 0.20, 0.15],
|
|
["m017", "Marcus Johnson", "member", 0.15, 0.55, 0.20],
|
|
["m018", "Yuna Park", "member", 0.20, 0.15, 0.55],
|
|
["m019", "Dmitri Volkov", "member", 0.45, 0.25, 0.20],
|
|
["m020", "Amara Diallo", "member", 0.15, 0.50, 0.25],
|
|
["m021", "Liam O'Connor", "member", 0.20, 0.20, 0.50],
|
|
["m022", "Zara Hussain", "member", 0.45, 0.15, 0.20],
|
|
["m023", "Tomás Herrera", "member", 0.20, 0.50, 0.15],
|
|
["m024", "Sakura Ito", "member", 0.15, 0.15, 0.50],
|
|
["m025", "Maya Johansson", "member", 0.30, 0.25, 0.35],
|
|
["m026", "Nia Mensah", "member", 0.15, 0.45, 0.20],
|
|
["m027", "Omar Farouk", "member", 0.40, 0.20, 0.25],
|
|
["m028", "Priya Sharma", "member", 0.15, 0.35, 0.30],
|
|
["m029", "Quinn O'Brien", "member", 0.30, 0.15, 0.35],
|
|
["m030", "Rosa Gutierrez", "member", 0.20, 0.30, 0.25],
|
|
["m031", "Sam Achebe", "member", 0.15, 0.15, 0.45],
|
|
["m032", "Tara Singh", "member", 0.10, 0.40, 0.15],
|
|
["m033", "Uri Goldberg", "member", 0.35, 0.15, 0.20],
|
|
["m034", "Valentina Costa", "member", 0.15, 0.30, 0.25],
|
|
["m035", "Wei Zhang", "member", 0.25, 0.20, 0.35],
|
|
["m036", "Eleni Papadopoulos", "member", 0.40, 0.15, 0.15],
|
|
["m037", "Kwame Asante", "member", 0.10, 0.40, 0.20],
|
|
["m038", "Astrid Lindgren", "member", 0.15, 0.15, 0.40],
|
|
["m039", "Ravi Kapoor", "member", 0.35, 0.20, 0.15],
|
|
["m040", "Nadia Petrov", "member", 0.15, 0.35, 0.20],
|
|
["m041", "Javier Morales", "member", 0.20, 0.10, 0.35],
|
|
["m042", "Asha Nair", "member", 0.30, 0.15, 0.20],
|
|
["m043", "Pierre Dubois", "member", 0.10, 0.30, 0.25],
|
|
["m044", "Hana Novak", "member", 0.20, 0.15, 0.30],
|
|
["m045", "Kofi Mensah", "member", 0.25, 0.20, 0.10],
|
|
["m046", "Isabella Romano", "member", 0.10, 0.30, 0.15],
|
|
["m047", "Lars Eriksson", "member", 0.15, 0.10, 0.30],
|
|
["m048", "Miriam Bauer", "member", 0.30, 0.15, 0.25],
|
|
["m049", "Kwesi Boateng", "member", 0.15, 0.35, 0.15],
|
|
["m050", "Sonia Pereira", "member", 0.20, 0.15, 0.35],
|
|
// ── Viewers (m051-m150): lower weights ──
|
|
["m051", "Aiden Murphy", "viewer", 0.10, 0.08, 0.05],
|
|
["m052", "Bianca Rossi", "viewer", 0.05, 0.12, 0.06],
|
|
["m053", "Carlos Vega", "viewer", 0.08, 0.05, 0.10],
|
|
["m054", "Diana Popescu", "viewer", 0.12, 0.06, 0.05],
|
|
["m055", "Erik Johansson", "viewer", 0.05, 0.10, 0.08],
|
|
["m056", "Fiona Walsh", "viewer", 0.06, 0.05, 0.12],
|
|
["m057", "Gustavo Lima", "viewer", 0.10, 0.08, 0.06],
|
|
["m058", "Helen Payne", "viewer", 0.05, 0.12, 0.08],
|
|
["m059", "Ivan Kozlov", "viewer", 0.08, 0.05, 0.10],
|
|
["m060", "Julia Fernández", "viewer", 0.12, 0.10, 0.05],
|
|
["m061", "Kenji Yamamoto", "viewer", 0.05, 0.06, 0.12],
|
|
["m062", "Luna Martínez", "viewer", 0.08, 0.10, 0.06],
|
|
["m063", "Mateo Cruz", "viewer", 0.10, 0.05, 0.08],
|
|
["m064", "Nina Kowalski", "viewer", 0.06, 0.12, 0.05],
|
|
["m065", "Oscar Blom", "viewer", 0.05, 0.08, 0.10],
|
|
["m066", "Petra Schwarzer", "viewer", 0.12, 0.05, 0.06],
|
|
["m067", "Ricardo Alves", "viewer", 0.08, 0.10, 0.05],
|
|
["m068", "Sofia Andersson", "viewer", 0.05, 0.06, 0.12],
|
|
["m069", "Tariq Ahmed", "viewer", 0.10, 0.08, 0.06],
|
|
["m070", "Uma Krishnan", "viewer", 0.06, 0.05, 0.10],
|
|
["m071", "Viktor Novak", "viewer", 0.08, 0.12, 0.05],
|
|
["m072", "Wendy Chu", "viewer", 0.05, 0.10, 0.08],
|
|
["m073", "Xander Visser", "viewer", 0.10, 0.05, 0.06],
|
|
["m074", "Yasmin El-Amin", "viewer", 0.06, 0.08, 0.12],
|
|
["m075", "Zoran Petrović", "viewer", 0.12, 0.06, 0.05],
|
|
["m076", "Ada Lovelace", "viewer", 0.05, 0.10, 0.08],
|
|
["m077", "Bruno Martins", "viewer", 0.08, 0.05, 0.12],
|
|
["m078", "Clara Bianchi", "viewer", 0.10, 0.12, 0.06],
|
|
["m079", "Daniel Okafor", "viewer", 0.06, 0.08, 0.10],
|
|
["m080", "Emilia Sánchez", "viewer", 0.12, 0.05, 0.08],
|
|
["m081", "Felix Braun", "viewer", 0.05, 0.10, 0.06],
|
|
["m082", "Greta Holm", "viewer", 0.08, 0.06, 0.12],
|
|
["m083", "Hugo Perrin", "viewer", 0.10, 0.08, 0.05],
|
|
["m084", "Isla Campbell", "viewer", 0.06, 0.12, 0.10],
|
|
["m085", "Jan Kowalczyk", "viewer", 0.05, 0.10, 0.08],
|
|
["m086", "Kira Sokolova", "viewer", 0.12, 0.05, 0.06],
|
|
["m087", "Luca Ferrari", "viewer", 0.08, 0.10, 0.05],
|
|
["m088", "Mila Horvat", "viewer", 0.05, 0.06, 0.12],
|
|
["m089", "Nils Hedberg", "viewer", 0.10, 0.08, 0.06],
|
|
["m090", "Olivia Jensen", "viewer", 0.06, 0.05, 0.10],
|
|
["m091", "Pavel Dvořák", "viewer", 0.08, 0.12, 0.05],
|
|
["m092", "Rosa Delgado", "viewer", 0.05, 0.10, 0.08],
|
|
["m093", "Stefan Ionescu", "viewer", 0.10, 0.05, 0.12],
|
|
["m094", "Teresa Gomes", "viewer", 0.06, 0.08, 0.10],
|
|
["m095", "Udo Fischer", "viewer", 0.12, 0.06, 0.05],
|
|
["m096", "Vera Smirnova", "viewer", 0.05, 0.10, 0.06],
|
|
["m097", "William Park", "viewer", 0.08, 0.05, 0.12],
|
|
["m098", "Xia Chen", "viewer", 0.10, 0.12, 0.08],
|
|
["m099", "Youssef Karam", "viewer", 0.06, 0.08, 0.05],
|
|
["m100", "Zlata Bogdanović", "viewer", 0.12, 0.05, 0.10],
|
|
["m101", "Arjun Mehta", "viewer", 0.05, 0.10, 0.06],
|
|
["m102", "Beatriz Nunes", "viewer", 0.08, 0.06, 0.12],
|
|
["m103", "Conrad Lehmann", "viewer", 0.10, 0.08, 0.05],
|
|
["m104", "Dahlia Osman", "viewer", 0.06, 0.12, 0.10],
|
|
["m105", "Elio Conti", "viewer", 0.05, 0.10, 0.08],
|
|
["m106", "Freya Bergman", "viewer", 0.12, 0.05, 0.06],
|
|
["m107", "George Adamu", "viewer", 0.08, 0.10, 0.05],
|
|
["m108", "Hilde Strand", "viewer", 0.05, 0.06, 0.12],
|
|
["m109", "Isak Nilsson", "viewer", 0.10, 0.08, 0.06],
|
|
["m110", "Jade Thompson", "viewer", 0.06, 0.05, 0.10],
|
|
["m111", "Karim Bouzid", "viewer", 0.08, 0.12, 0.05],
|
|
["m112", "Leila Sharif", "viewer", 0.05, 0.10, 0.08],
|
|
["m113", "Marco Colombo", "viewer", 0.10, 0.05, 0.12],
|
|
["m114", "Naomi Okeke", "viewer", 0.06, 0.08, 0.10],
|
|
["m115", "Otto Muller", "viewer", 0.12, 0.06, 0.05],
|
|
["m116", "Pilar Reyes", "viewer", 0.05, 0.10, 0.06],
|
|
["m117", "Ragnar Haugen", "viewer", 0.08, 0.05, 0.12],
|
|
["m118", "Selma Kaya", "viewer", 0.10, 0.12, 0.08],
|
|
["m119", "Theo Laurent", "viewer", 0.06, 0.08, 0.05],
|
|
["m120", "Ulrike Becker", "viewer", 0.12, 0.05, 0.10],
|
|
["m121", "Vito Moretti", "viewer", 0.05, 0.10, 0.06],
|
|
["m122", "Wanda Kwiatkowska", "viewer", 0.08, 0.06, 0.12],
|
|
["m123", "Xavier Dumont", "viewer", 0.10, 0.08, 0.05],
|
|
["m124", "Yara Costa", "viewer", 0.06, 0.12, 0.10],
|
|
["m125", "Zane Mitchell", "viewer", 0.05, 0.10, 0.08],
|
|
["m126", "Anya Volkov", "viewer", 0.12, 0.05, 0.06],
|
|
["m127", "Bastian Krüger", "viewer", 0.08, 0.10, 0.05],
|
|
["m128", "Celeste Dupont", "viewer", 0.05, 0.06, 0.12],
|
|
["m129", "Dario Mancini", "viewer", 0.10, 0.08, 0.06],
|
|
["m130", "Elena Todorov", "viewer", 0.06, 0.05, 0.10],
|
|
["m131", "Finn O'Sullivan", "viewer", 0.08, 0.12, 0.05],
|
|
["m132", "Giulia Rizzo", "viewer", 0.05, 0.10, 0.08],
|
|
["m133", "Henrik Dahl", "viewer", 0.10, 0.05, 0.12],
|
|
["m134", "Irene Papazoglou", "viewer", 0.06, 0.08, 0.10],
|
|
["m135", "Jakob Andersen", "viewer", 0.12, 0.06, 0.05],
|
|
["m136", "Kamila Szymańska", "viewer", 0.05, 0.10, 0.06],
|
|
["m137", "Leon Hartmann", "viewer", 0.08, 0.05, 0.12],
|
|
["m138", "Maria Alonso", "viewer", 0.10, 0.12, 0.08],
|
|
["m139", "Noah Bakker", "viewer", 0.06, 0.08, 0.05],
|
|
["m140", "Olga Fedorova", "viewer", 0.12, 0.05, 0.10],
|
|
["m141", "Patrick Byrne", "viewer", 0.05, 0.10, 0.06],
|
|
["m142", "Renata Vlad", "viewer", 0.08, 0.06, 0.12],
|
|
["m143", "Sven Lund", "viewer", 0.10, 0.08, 0.05],
|
|
["m144", "Tatiana Morozova", "viewer", 0.06, 0.12, 0.10],
|
|
["m145", "Umberto Greco", "viewer", 0.05, 0.10, 0.08],
|
|
["m146", "Violeta Stoica", "viewer", 0.12, 0.05, 0.06],
|
|
["m147", "Walter Schmidt", "viewer", 0.08, 0.10, 0.05],
|
|
["m148", "Xiomara Ríos", "viewer", 0.05, 0.06, 0.12],
|
|
["m149", "Yannick Morel", "viewer", 0.10, 0.08, 0.06],
|
|
["m150", "Zuzana Horváthová", "viewer", 0.06, 0.05, 0.10],
|
|
];
|
|
|
|
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 },
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Generate delegation edges deterministically ──
|
|
function generateDemoDelegations(
|
|
members: Array<[string, string, string, number, number, number]>,
|
|
authority: string,
|
|
weightIdx: number, // 3=gov, 4=fin, 5=dev
|
|
seed: number,
|
|
): Array<[string, string, number]> {
|
|
const rng = mulberry32(seed);
|
|
const result: Array<[string, string, number]> = [];
|
|
const outboundSum = new Map<string, number>();
|
|
|
|
const admins = members.filter(m => m[2] === "admin");
|
|
const mems = members.filter(m => m[2] === "member");
|
|
const viewers = members.filter(m => m[2] === "viewer");
|
|
|
|
// Viewers → members/admins (2-3 edges each)
|
|
for (const v of viewers) {
|
|
const edgeCount = 2 + (rng() < 0.4 ? 1 : 0);
|
|
const targets = [...mems, ...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
|
|
let remaining = Math.min(v[weightIdx], 0.9);
|
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
|
const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.6 * 100) / 100 : Math.round(remaining * 100) / 100;
|
|
const clamped = Math.min(w, remaining);
|
|
if (clamped > 0.01) {
|
|
result.push([v[0], targets[i][0], clamped]);
|
|
remaining -= clamped;
|
|
outboundSum.set(v[0], (outboundSum.get(v[0]) || 0) + clamped);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Members → admins (3-4 edges each)
|
|
for (const m of mems) {
|
|
const edgeCount = 3 + (rng() < 0.3 ? 1 : 0);
|
|
const targets = [...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
|
|
let remaining = Math.min(m[weightIdx], 0.95) - (outboundSum.get(m[0]) || 0);
|
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
|
const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.5 * 100) / 100 : Math.round(remaining * 100) / 100;
|
|
const clamped = Math.min(w, remaining);
|
|
if (clamped > 0.01) {
|
|
result.push([m[0], targets[i][0], clamped]);
|
|
remaining -= clamped;
|
|
outboundSum.set(m[0], (outboundSum.get(m[0]) || 0) + clamped);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Admins → other admins (1-2 edges each)
|
|
for (const a of admins) {
|
|
const edgeCount = 1 + (rng() < 0.4 ? 1 : 0);
|
|
const targets = admins.filter(t => t[0] !== a[0]).sort(() => rng() - 0.5).slice(0, edgeCount);
|
|
let remaining = Math.min(a[weightIdx] * 0.4, 0.5) - (outboundSum.get(a[0]) || 0);
|
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
|
const w = Math.round(Math.min(rng() * 0.3 + 0.05, remaining) * 100) / 100;
|
|
if (w > 0.01) {
|
|
result.push([a[0], targets[i][0], w]);
|
|
remaining -= w;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const govDelegations = generateDemoDelegations(members, "gov-ops", 3, 42);
|
|
const finDelegations = generateDemoDelegations(members, "fin-ops", 4, 137);
|
|
const devDelegations = generateDemoDelegations(members, "dev-ops", 5, 271);
|
|
|
|
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: `<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." },
|
|
],
|
|
},
|
|
],
|
|
};
|