| 1 | // The HubSpot companion app (docs/integrations.md § companion apps). Once |
| 2 | // you've connected HubSpot in Integrations (OAuth or a private-app token), |
| 3 | // this app installs: recent contacts and deals as a small dashboard in its |
| 4 | // own thread. |
| 5 | // |
| 6 | // Same rail as the GitHub and Stripe apps: NO HubSpot sidecar. Every fetch |
| 7 | // goes through ctx.host.connected.requestOp (a one-shot read grant sealed to |
| 8 | // the instance's existing bot); results are bounded by the hubspot manifest's |
| 9 | // `pick` projections, fold into state as typed events, and sync across |
| 10 | // devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes |
| 11 | // into chat bubbles or notifications. |
| 12 | import { defineApp } from "../apps.js"; |
| 13 | import { LOGO_HUBSPOT } from "../logos.js"; |
| 14 | |
| 15 | // Results arrive pick-projected with flattened dot-path keys |
| 16 | // ("properties.email") — normalise once at fold time, drop the malformed. |
| 17 | const normContacts = (list) => |
| 18 | (Array.isArray(list) ? list : []) |
| 19 | .filter((c) => c && typeof c.id === "string") |
| 20 | .map((c) => ({ |
| 21 | id: c.id, |
| 22 | email: typeof c["properties.email"] === "string" ? c["properties.email"] : "", |
| 23 | first: typeof c["properties.firstname"] === "string" ? c["properties.firstname"] : "", |
| 24 | last: typeof c["properties.lastname"] === "string" ? c["properties.lastname"] : "", |
| 25 | created: typeof c.createdAt === "string" ? c.createdAt : "", |
| 26 | })); |
| 27 | |
| 28 | const normDeals = (list) => |
| 29 | (Array.isArray(list) ? list : []) |
| 30 | .filter((d) => d && typeof d.id === "string") |
| 31 | .map((d) => ({ |
| 32 | id: d.id, |
| 33 | name: typeof d["properties.dealname"] === "string" ? d["properties.dealname"] : "", |
| 34 | amount: typeof d["properties.amount"] === "string" ? d["properties.amount"] : "", |
| 35 | stage: typeof d["properties.dealstage"] === "string" ? d["properties.dealstage"] : "", |
| 36 | close: typeof d["properties.closedate"] === "string" ? d["properties.closedate"] : "", |
| 37 | created: typeof d.createdAt === "string" ? d.createdAt : "", |
| 38 | })); |
| 39 | |
| 40 | // ISO stamp → short local date; "" when unparsable. |
| 41 | function day(iso) { |
| 42 | const t = Date.parse(iso || ""); |
| 43 | if (!t) return ""; |
| 44 | try { |
| 45 | return new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" }); |
| 46 | } catch { |
| 47 | return ""; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // HubSpot deal stages are internal slugs ("appointmentscheduled") — make them |
| 52 | // readable without pretending to know the pipeline's real labels. |
| 53 | const stageLabel = (s) => (s || "").replace(/[_-]+/g, " ").trim() || "—"; |
| 54 | |
| 55 | let busy = false; |
| 56 | let autoLoading = false; |
| 57 | |
| 58 | export const app = defineApp({ |
| 59 | name: "hubspot", |
| 60 | title: "HubSpot", |
| 61 | blurb: "Contacts & deals", |
| 62 | glyph: "🧲", |
| 63 | mark: "hubspot", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) |
| 64 | logo: LOGO_HUBSPOT, |
| 65 | places: ["you"], |
| 66 | // Only listed in New chat once HubSpot is linked (shell.js gates on this). |
| 67 | requiresService: "hubspot", |
| 68 | initialState: () => ({ contacts: [], deals: [], fetchedAt: 0 }), |
| 69 | |
| 70 | events: { |
| 71 | "hubspot.contacts": (s, e) => (Array.isArray(e.contacts) ? { ...s, contacts: normContacts(e.contacts), fetchedAt: e.ts || s.fetchedAt } : s), |
| 72 | "hubspot.deals": (s, e) => (Array.isArray(e.deals) ? { ...s, deals: normDeals(e.deals), fetchedAt: e.ts || s.fetchedAt } : s), |
| 73 | }, |
| 74 | |
| 75 | styles: ` |
| 76 | .hs-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } |
| 77 | .hs-title { margin: 0; font-size: 18px; } |
| 78 | .hs-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } |
| 79 | .hs-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); } |
| 80 | .hs-btn:disabled { opacity: .6; cursor: default; } |
| 81 | .hs-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); } |
| 82 | .hs-scroll { overflow-x: auto; margin: 0 -4px; } |
| 83 | .hs-table { width: 100%; border-collapse: collapse; font-size: 14px; } |
| 84 | .hs-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } |
| 85 | .hs-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } |
| 86 | .hs-name { font-weight: 500; overflow-wrap: anywhere; } |
| 87 | .hs-muted { color: var(--muted); overflow-wrap: anywhere; } |
| 88 | .hs-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; } |
| 89 | .hs-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| 90 | .hs-empty { color: var(--muted); padding: 8px 0; } |
| 91 | .hs-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } |
| 92 | `, |
| 93 | |
| 94 | render(state, el, ctx) { |
| 95 | const host = ctx.host && ctx.host.connected; |
| 96 | state = state || { contacts: [], deals: [], fetchedAt: 0 }; |
| 97 | |
| 98 | const head = document.createElement("div"); |
| 99 | head.className = "hs-head"; |
| 100 | el.appendChild(head); |
| 101 | |
| 102 | const title = document.createElement("h2"); |
| 103 | title.className = "hs-title"; |
| 104 | title.textContent = "HubSpot"; |
| 105 | const sub = document.createElement("span"); |
| 106 | sub.className = "hs-sub"; |
| 107 | |
| 108 | const note = document.createElement("div"); |
| 109 | note.className = "hs-note"; |
| 110 | |
| 111 | if (!host || !host.requestOp) { |
| 112 | head.appendChild(title); |
| 113 | const empty = document.createElement("div"); |
| 114 | empty.className = "hs-empty"; |
| 115 | empty.textContent = "HubSpot needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; |
| 116 | el.append(empty); |
| 117 | return; |
| 118 | } |
| 119 | |
| 120 | // One Refresh pulls both reads; each folds as its own event so a failing |
| 121 | // one never blanks the other. Whole body under try/finally so a throw can |
| 122 | // never wedge the module-level busy flag. |
| 123 | const refresh = async () => { |
| 124 | if (busy) return; |
| 125 | busy = true; |
| 126 | const errors = []; |
| 127 | try { |
| 128 | note.textContent = ""; |
| 129 | paint(); |
| 130 | const pulls = [ |
| 131 | ["contacts.list", (d) => ctx.emit({ type: "hubspot.contacts", contacts: d })], |
| 132 | ["deals.list", (d) => ctx.emit({ type: "hubspot.deals", deals: d })], |
| 133 | ]; |
| 134 | await Promise.all( |
| 135 | pulls.map(async ([op, emit]) => { |
| 136 | let res; |
| 137 | try { |
| 138 | res = await host.requestOp("hubspot", op, {}); |
| 139 | } catch (e) { |
| 140 | res = { ok: false, error: (e && e.message) || String(e) }; |
| 141 | } |
| 142 | if (res && res.ok && typeof res.data === "string") { |
| 143 | try { |
| 144 | emit(JSON.parse(res.data)); |
| 145 | return; |
| 146 | } catch {} |
| 147 | } |
| 148 | errors.push(`${op}: ${(res && res.error) || "no response"}`); |
| 149 | }), |
| 150 | ); |
| 151 | } finally { |
| 152 | busy = false; |
| 153 | autoLoading = false; |
| 154 | } |
| 155 | if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`; |
| 156 | paint(); |
| 157 | }; |
| 158 | |
| 159 | const refreshBtn = document.createElement("button"); |
| 160 | refreshBtn.type = "button"; |
| 161 | refreshBtn.className = "hs-btn"; |
| 162 | refreshBtn.textContent = "↻ Refresh"; |
| 163 | refreshBtn.onclick = refresh; |
| 164 | head.append(title, sub, refreshBtn); |
| 165 | |
| 166 | const section = (heading, rows, cols, rowCells) => { |
| 167 | const h = document.createElement("div"); |
| 168 | h.className = "hs-h"; |
| 169 | h.textContent = heading; |
| 170 | el.appendChild(h); |
| 171 | if (!rows.length) { |
| 172 | const empty = document.createElement("div"); |
| 173 | empty.className = "hs-empty"; |
| 174 | empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh."; |
| 175 | el.appendChild(empty); |
| 176 | return; |
| 177 | } |
| 178 | const scroll = document.createElement("div"); |
| 179 | scroll.className = "hs-scroll"; |
| 180 | const table = document.createElement("table"); |
| 181 | table.className = "hs-table"; |
| 182 | const thead = document.createElement("thead"); |
| 183 | const hr = document.createElement("tr"); |
| 184 | for (const c of cols) { |
| 185 | const th = document.createElement("th"); |
| 186 | th.textContent = c; |
| 187 | hr.appendChild(th); |
| 188 | } |
| 189 | thead.appendChild(hr); |
| 190 | table.appendChild(thead); |
| 191 | const tbody = document.createElement("tbody"); |
| 192 | for (const r of rows) { |
| 193 | const tr = document.createElement("tr"); |
| 194 | for (const cell of rowCells(r)) tr.appendChild(cell); |
| 195 | tbody.appendChild(tr); |
| 196 | } |
| 197 | table.appendChild(tbody); |
| 198 | scroll.appendChild(table); |
| 199 | el.appendChild(scroll); |
| 200 | }; |
| 201 | |
| 202 | const td = (text, cls) => { |
| 203 | const c = document.createElement("td"); |
| 204 | if (cls) c.className = cls; |
| 205 | c.textContent = text; |
| 206 | return c; |
| 207 | }; |
| 208 | |
| 209 | section("Recent contacts", state.contacts, ["Name", "Email", "Added"], (c) => [ |
| 210 | td([c.first, c.last].filter(Boolean).join(" ") || "—", "hs-name"), |
| 211 | td(c.email || "—", "hs-muted"), |
| 212 | td(day(c.created), "hs-when"), |
| 213 | ]); |
| 214 | section("Recent deals", state.deals, ["Deal", "Amount", "Stage", "Closes"], (d) => [ |
| 215 | td(d.name || "—", "hs-name"), |
| 216 | td(d.amount ? d.amount : "—", "hs-amount"), |
| 217 | td(stageLabel(d.stage), "hs-muted"), |
| 218 | td(day(d.close), "hs-when"), |
| 219 | ]); |
| 220 | |
| 221 | el.appendChild(note); |
| 222 | |
| 223 | const paint = () => { |
| 224 | sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""; |
| 225 | refreshBtn.disabled = busy; |
| 226 | }; |
| 227 | paint(); |
| 228 | |
| 229 | // First open pulls once, automatically — after the surface is fully built. |
| 230 | if (!state.fetchedAt && !busy && !autoLoading) { |
| 231 | autoLoading = true; |
| 232 | refresh(); |
| 233 | } |
| 234 | }, |
| 235 | }); |
| 236 | |