From 7e5499d087259a419221cdfaaf54fef5c3a58209 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 18:17:09 -0800 Subject: [PATCH] feat: add multi-workspace Twenty CRM support & CRM view component Enable multi-workspace mode in Twenty docker-compose with per-space API token routing. Add folk-crm-view component with Pipeline, Contacts, Companies, and Graph tabs. Co-Authored-By: Claude Opus 4.6 --- deploy/twenty-crm/docker-compose.yml | 10 + modules/rnetwork/components/folk-crm-view.ts | 495 +++++++++++++++++++ modules/rnetwork/mod.ts | 111 +++-- 3 files changed, 588 insertions(+), 28 deletions(-) create mode 100644 modules/rnetwork/components/folk-crm-view.ts diff --git a/deploy/twenty-crm/docker-compose.yml b/deploy/twenty-crm/docker-compose.yml index aced15e..197fece 100644 --- a/deploy/twenty-crm/docker-compose.yml +++ b/deploy/twenty-crm/docker-compose.yml @@ -38,6 +38,11 @@ services: - SIGN_IN_PREFILLED=false - IS_BILLING_ENABLED=false - TELEMETRY_ENABLED=false + # ── Multi-workspace ── + - IS_MULTIWORKSPACE_ENABLED=true + - IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true + - DEFAULT_SUBDOMAIN=admin-crm + - FRONT_DOMAIN=rspace.online volumes: - twenty-ch-server-data:/app/.local-storage labels: @@ -76,6 +81,11 @@ services: - STORAGE_LOCAL_PATH=.local-storage - SERVER_URL=https://crm.rspace.online - TELEMETRY_ENABLED=false + # ── Multi-workspace ── + - IS_MULTIWORKSPACE_ENABLED=true + - IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true + - DEFAULT_SUBDOMAIN=admin-crm + - FRONT_DOMAIN=rspace.online volumes: - twenty-ch-server-data:/app/.local-storage networks: diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts new file mode 100644 index 0000000..4693e88 --- /dev/null +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -0,0 +1,495 @@ +/** + * — API-driven CRM interface. + * + * Tabbed view: Pipeline | Contacts | Companies | Graph + * Fetches from per-space API endpoints on the rNetwork module. + */ + +interface Opportunity { + id: string; + name: string; + stage: string; + amount?: { amountMicros: number; currencyCode: string }; + company?: { id: string; name: string }; + pointOfContact?: { id: string; name: { firstName: string; lastName: string } }; + createdAt?: string; + closeDate?: string; +} + +interface Person { + id: string; + name: { firstName: string; lastName: string }; + email?: { primaryEmail: string }; + phone?: { primaryPhoneNumber: string }; + city?: string; + company?: { id: string; name: { firstName: string; lastName: string } }; + createdAt?: string; +} + +interface Company { + id: string; + name: string; + domainName?: { primaryLinkUrl: string }; + employees?: number; + address?: { addressCity: string; addressCountry: string }; + createdAt?: string; +} + +type Tab = "pipeline" | "contacts" | "companies" | "graph"; + +const PIPELINE_STAGES = [ + "INCOMING", + "MEETING", + "PROPOSAL", + "CLOSED_WON", + "CLOSED_LOST", +]; + +const STAGE_LABELS: Record = { + INCOMING: "Incoming", + MEETING: "Meeting", + PROPOSAL: "Proposal", + CLOSED_WON: "Won", + CLOSED_LOST: "Lost", +}; + +class FolkCrmView extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private activeTab: Tab = "pipeline"; + private searchQuery = ""; + private sortColumn = ""; + private sortAsc = true; + + private opportunities: Opportunity[] = []; + private people: Person[] = []; + private companies: Company[] = []; + private loading = true; + private error = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.render(); + this.loadData(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rnetwork/); + return match ? match[0] : ""; + } + + private async loadData() { + const base = this.getApiBase(); + try { + const [oppRes, peopleRes, compRes] = await Promise.all([ + fetch(`${base}/api/opportunities`), + fetch(`${base}/api/people`), + fetch(`${base}/api/companies`), + ]); + if (oppRes.ok) { + const d = await oppRes.json(); + this.opportunities = d.opportunities || []; + } + if (peopleRes.ok) { + const d = await peopleRes.json(); + this.people = d.people || []; + } + if (compRes.ok) { + const d = await compRes.json(); + this.companies = d.companies || []; + } + } catch (e) { + this.error = "Failed to load CRM data"; + } + this.loading = false; + this.render(); + } + + private formatAmount(amount?: { amountMicros: number; currencyCode: string }): string { + if (!amount || !amount.amountMicros) return "-"; + const value = amount.amountMicros / 1_000_000; + const currency = amount.currencyCode || "USD"; + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }).format(value); + } catch { + return `${currency} ${value.toLocaleString()}`; + } + } + + private personName(p?: { firstName: string; lastName: string }): string { + if (!p) return "-"; + return [p.firstName, p.lastName].filter(Boolean).join(" ") || "-"; + } + + private companyName(c?: { id: string; name: string | { firstName: string; lastName: string } }): string { + if (!c) return "-"; + if (typeof c.name === "string") return c.name; + return [c.name?.firstName, c.name?.lastName].filter(Boolean).join(" ") || "-"; + } + + private formatDate(d?: string): string { + if (!d) return "-"; + try { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } + catch { return "-"; } + } + + // ── Pipeline view ── + private renderPipeline(): string { + if (this.opportunities.length === 0) { + return `
No opportunities found${this.space === "demo" ? " — connect a Twenty workspace to see pipeline data" : ""}.
`; + } + + const byStage = new Map(); + for (const stage of PIPELINE_STAGES) byStage.set(stage, []); + for (const opp of this.opportunities) { + const stage = opp.stage || "INCOMING"; + const list = byStage.get(stage) || byStage.get("INCOMING")!; + list.push(opp); + } + + return `
+ ${PIPELINE_STAGES.map(stage => { + const opps = byStage.get(stage) || []; + const total = opps.reduce((s, o) => s + (o.amount?.amountMicros || 0), 0) / 1_000_000; + const stageClass = stage.toLowerCase().replace(/_/g, "-"); + return `
+
+ ${STAGE_LABELS[stage] || stage} + ${opps.length} +
+ ${total > 0 ? `
${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}
` : ""} +
+ ${opps.map(opp => `
+
${this.esc(opp.name)}
+
${this.formatAmount(opp.amount)}
+ ${opp.company?.name ? `
${this.esc(opp.company.name)}
` : ""} + ${opp.pointOfContact ? `
${this.esc(this.personName(opp.pointOfContact.name))}
` : ""} + ${opp.closeDate ? `
Close: ${this.formatDate(opp.closeDate)}
` : ""} +
`).join("")} +
+
`; + }).join("")} +
`; + } + + // ── Contacts table ── + private renderContacts(): string { + let filtered = this.people; + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase(); + filtered = filtered.filter(p => + this.personName(p.name).toLowerCase().includes(q) || + (p.email?.primaryEmail || "").toLowerCase().includes(q) || + this.companyName(p.company).toLowerCase().includes(q) || + (p.city || "").toLowerCase().includes(q) + ); + } + + if (this.sortColumn) { + filtered = [...filtered].sort((a, b) => { + let va = "", vb = ""; + switch (this.sortColumn) { + case "name": va = this.personName(a.name); vb = this.personName(b.name); break; + case "email": va = a.email?.primaryEmail || ""; vb = b.email?.primaryEmail || ""; break; + case "company": va = this.companyName(a.company); vb = this.companyName(b.company); break; + case "city": va = a.city || ""; vb = b.city || ""; break; + } + const cmp = va.localeCompare(vb); + return this.sortAsc ? cmp : -cmp; + }); + } + + if (filtered.length === 0) { + return `
${this.searchQuery ? "No contacts match your search." : "No contacts found."}
`; + } + + const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : ""; + + return ` + + + + + + + + + ${filtered.map(p => ` + + + + + + `).join("")} + +
Name${sortIcon("name")}Email${sortIcon("email")}Company${sortIcon("company")}City${sortIcon("city")}Phone
${this.esc(this.personName(p.name))}${this.esc(this.companyName(p.company))}${this.esc(p.city || "-")}${this.esc(p.phone?.primaryPhoneNumber || "-")}
+ `; + } + + // ── Companies table ── + private renderCompanies(): string { + let filtered = this.companies; + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase(); + filtered = filtered.filter(c => + (c.name || "").toLowerCase().includes(q) || + (c.domainName?.primaryLinkUrl || "").toLowerCase().includes(q) || + (c.address?.addressCity || "").toLowerCase().includes(q) + ); + } + + if (this.sortColumn) { + filtered = [...filtered].sort((a, b) => { + let va = "", vb = ""; + switch (this.sortColumn) { + case "name": va = a.name || ""; vb = b.name || ""; break; + case "domain": va = a.domainName?.primaryLinkUrl || ""; vb = b.domainName?.primaryLinkUrl || ""; break; + case "city": va = a.address?.addressCity || ""; vb = b.address?.addressCity || ""; break; + case "employees": return this.sortAsc ? (a.employees || 0) - (b.employees || 0) : (b.employees || 0) - (a.employees || 0); + } + const cmp = va.localeCompare(vb); + return this.sortAsc ? cmp : -cmp; + }); + } + + if (filtered.length === 0) { + return `
${this.searchQuery ? "No companies match your search." : "No companies found."}
`; + } + + const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : ""; + + return ` + + + + + + + + + ${filtered.map(c => ` + + + + + + `).join("")} + +
Name${sortIcon("name")}Domain${sortIcon("domain")}Employees${sortIcon("employees")}City${sortIcon("city")}Country
${this.esc(c.name || "-")}${c.domainName?.primaryLinkUrl ? `${this.esc(c.domainName.primaryLinkUrl)}` : "-"}${c.employees ?? "-"}${this.esc(c.address?.addressCity || "-")}${this.esc(c.address?.addressCountry || "-")}
+ `; + } + + // ── Graph link ── + private renderGraphTab(): string { + const base = this.getApiBase().replace(/\/crm$/, ""); + return ``; + } + + // ── Main render ── + private render() { + const tabs: { id: Tab; label: string; count: number }[] = [ + { id: "pipeline", label: "Pipeline", count: this.opportunities.length }, + { id: "contacts", label: "Contacts", count: this.people.length }, + { id: "companies", label: "Companies", count: this.companies.length }, + { id: "graph", label: "Graph", count: 0 }, + ]; + + let content = ""; + if (this.loading) { + content = `
Loading CRM data...
`; + } else if (this.error) { + content = `
${this.esc(this.error)}
`; + } else { + switch (this.activeTab) { + case "pipeline": content = this.renderPipeline(); break; + case "contacts": content = this.renderContacts(); break; + case "companies": content = this.renderCompanies(); break; + case "graph": content = this.renderGraphTab(); break; + } + } + + const showSearch = this.activeTab === "contacts" || this.activeTab === "companies"; + + this.shadow.innerHTML = ` + + +
+ CRM +
+ +
+ ${tabs.map(t => ``).join("")} +
+ + ${showSearch ? `
+ +
` : ""} + +
${content}
+ `; + + this.attachListeners(); + } + + private attachListeners() { + // Tab switching + this.shadow.querySelectorAll("[data-tab]").forEach(el => { + el.addEventListener("click", () => { + this.activeTab = (el as HTMLElement).dataset.tab as Tab; + this.searchQuery = ""; + this.sortColumn = ""; + this.sortAsc = true; + this.render(); + }); + }); + + // Search + let searchTimeout: any; + this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => { + this.searchQuery = (e.target as HTMLInputElement).value; + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => this.render(), 200); + }); + + // Sort columns + this.shadow.querySelectorAll("[data-sort]").forEach(el => { + el.addEventListener("click", () => { + const col = (el as HTMLElement).dataset.sort!; + if (this.sortColumn === col) { + this.sortAsc = !this.sortAsc; + } else { + this.sortColumn = col; + this.sortAsc = true; + } + this.render(); + }); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-crm-view", FolkCrmView); diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 751e1c1..fd70a5b 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -7,24 +7,38 @@ */ import { Hono } from "hono"; -import { renderShell, renderExternalAppShell } from "../../server/shell"; +import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; 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 || ""; +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) { - if (!TWENTY_API_TOKEN) return null; +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}/api`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${TWENTY_API_TOKEN}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(10000), @@ -34,28 +48,35 @@ async function twentyQuery(query: string, variables?: Record) { return json.data ?? null; } -// ── Cache layer (60s TTL) ── -let graphCache: { data: unknown; ts: number } | null = null; +// ── Per-space cache layer (60s TTL) ── +const graphCaches = new Map(); const CACHE_TTL = 60_000; // ── API: Health ── routes.get("/api/health", (c) => { - return c.json({ ok: true, module: "network", twentyConfigured: !!TWENTY_API_TOKEN }); + const space = c.req.param("space") || "demo"; + const token = getTokenForSpace(space); + 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 token = getTokenForSpace(space); 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, + space, + twentyConfigured: !!token, }); }); // ── API: People ── routes.get("/api/people", async (c) => { + const space = c.req.param("space") || "demo"; + const token = getTokenForSpace(space); const data = await twentyQuery(`{ people(first: 200) { edges { @@ -70,8 +91,8 @@ routes.get("/api/people", async (c) => { } } } - }`); - if (!data) return c.json({ people: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" }); + }`, undefined, space); + 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 }); @@ -79,6 +100,8 @@ routes.get("/api/people", async (c) => { // ── API: Companies ── routes.get("/api/companies", async (c) => { + const space = c.req.param("space") || "demo"; + const token = getTokenForSpace(space); const data = await twentyQuery(`{ companies(first: 200) { edges { @@ -92,8 +115,8 @@ routes.get("/api/companies", async (c) => { } } } - }`); - if (!data) return c.json({ companies: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" }); + }`, undefined, space); + 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 }); @@ -101,13 +124,17 @@ routes.get("/api/companies", async (c) => { // ── API: Graph — transform entities to node/edge format ── routes.get("/api/graph", async (c) => { - // Check cache - if (graphCache && Date.now() - graphCache.ts < CACHE_TTL) { + const space = c.req.param("space") || "demo"; + const token = getTokenForSpace(space); + + // Check per-space cache + const cached = graphCaches.get(space); + if (cached && Date.now() - cached.ts < CACHE_TTL) { c.header("Cache-Control", "public, max-age=60"); - return c.json(graphCache.data); + return c.json(cached.data); } - if (!TWENTY_API_TOKEN) { + if (!token) { return c.json({ nodes: [ { id: "demo-1", label: "Alice", type: "person", data: {} }, @@ -157,7 +184,7 @@ routes.get("/api/graph", async (c) => { } } } - }`); + }`, undefined, space); if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" }); @@ -198,7 +225,7 @@ routes.get("/api/graph", async (c) => { } const result = { nodes, edges, demo: false }; - graphCache = { data: result, ts: Date.now() }; + graphCaches.set(space, { data: result, ts: Date.now() }); c.header("Cache-Control", "public, max-age=60"); return c.json(result); } catch (e) { @@ -214,17 +241,44 @@ routes.get("/api/workspaces", (c) => { ]); }); -// ── CRM sub-route — dedicated iframe to Twenty CRM ── +// ── API: Opportunities ── +routes.get("/api/opportunities", async (c) => { + const space = c.req.param("space") || "demo"; + const token = getTokenForSpace(space); + 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, space); + 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 ── routes.get("/crm", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderExternalAppShell({ + return c.html(renderShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), - appUrl: "https://crm.rspace.online", - appName: "Twenty CRM", theme: "dark", + body: ``, + scripts: ``, + styles: ``, })); }); @@ -234,14 +288,15 @@ routes.get("/", (c) => { const view = c.req.query("view"); if (view === "app") { - return c.html(renderExternalAppShell({ - title: `${space} — Twenty CRM | rSpace`, + return c.html(renderShell({ + title: `${space} — CRM | rSpace`, moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), - appUrl: "https://demo.rnetwork.online", - appName: "Twenty CRM", theme: "dark", + body: ``, + scripts: ``, + styles: ``, })); }