Merge remote-tracking branch 'origin/feat/hubspot'
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1,189 @@
|
||||
# Hanzo AI for HubSpot
|
||||
|
||||
Brings Hanzo AI into a sales, marketing, or customer-success team's **HubSpot**
|
||||
CRM. Open the **Hanzo AI** tab on any contact, company, or deal record and Hanzo
|
||||
will:
|
||||
|
||||
- **Summarize this record** — read the record and its associated records
|
||||
(a contact's companies + deals, a deal's contacts + companies, …) and produce
|
||||
a tight, rep-ready overview.
|
||||
- **Draft email** — generate a short, personalized follow-up grounded in the
|
||||
record data, ready to send.
|
||||
- **Next steps** — a prioritized list of concrete actions for this record.
|
||||
- **Ask** — freeform Q&A over the record and its context.
|
||||
|
||||
Every result can be **written back to HubSpot** with one click — logged as a
|
||||
**Note** or created as a follow-up **Task** on the record, associated via the
|
||||
platform's default engagement association. Model calls route through the same
|
||||
`api.hanzo.ai/v1` gateway as every other Hanzo surface, using the published
|
||||
[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai) headless client.
|
||||
|
||||
This is a HubSpot **public app** on the developer-projects platform: a **CRM card
|
||||
UI extension** (React) backed by **serverless functions**. The Hanzo API key and
|
||||
the per-portal OAuth token live only in the serverless backend — **they never
|
||||
reach the browser**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two credentials, kept apart by design:
|
||||
|
||||
- **HubSpot** — OAuth 2.0 authorization-code grant. The `client_secret` lives
|
||||
only in the app's OAuth exchange (see `src/oauth.ts` for the exact wire shape);
|
||||
the resulting per-portal access token is injected by HubSpot into the
|
||||
serverless runtime as `process.env.PRIVATE_APP_ACCESS_TOKEN`. The React card
|
||||
never sees it — it reads/writes CRM data only by calling the serverless
|
||||
functions.
|
||||
- **Hanzo** — an `hk-…` API key stored as a HubSpot **secret**
|
||||
(`HANZO_API_KEY`), read by the `hanzo-assistant` serverless function and passed
|
||||
to `@hanzo/ai`. The browser only ever receives the final generated text.
|
||||
|
||||
```
|
||||
CRM card (HanzoAssistant.tsx, React, no window/fetch)
|
||||
│ runServerlessFunction('hanzo-assistant', { objectType, objectId, action, detail, model })
|
||||
▼
|
||||
app.functions/hanzo-assistant.js (holds HANZO_API_KEY; OAuth token from env)
|
||||
├── @hubspot/api-client ── read record + associations (getById, associations.v4, batch.read)
|
||||
├── @hanzo/hubspot (pure core) ── buildContextBlocks → assembleRecordContext (windowed)
|
||||
└── @hanzo/ai createAiClient ── POST api.hanzo.ai/v1/chat/completions
|
||||
◀── { text, truncated }
|
||||
|
||||
CRM card ── runServerlessFunction('hanzo-writeback', { kind, objectType, objectId, body })
|
||||
▼
|
||||
app.functions/hanzo-writeback.js
|
||||
└── noteWriteBack / taskWriteBack (pure) → POST /crm/v3/objects/{notes,tasks}
|
||||
```
|
||||
|
||||
The **pure core** (`src/config.ts`, `src/hanzo.ts`, `src/oauth.ts`,
|
||||
`src/record.ts`, `src/api/*`) has no HubSpot runtime and no DOM dependency — it
|
||||
is the `@hanzo/hubspot` package the serverless functions import, and it is
|
||||
covered by the vitest suite. The `.tsx` card and the `.js` serverless functions
|
||||
are the two thin edges HubSpot's own tooling bundles and runs.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts Hanzo + HubSpot endpoints, object types, context budget
|
||||
hanzo.ts the single model path (over @hanzo/ai) + context windowing + the 4 actions
|
||||
oauth.ts HubSpot OAuth token-exchange request shaping
|
||||
record.ts CRM record → ordered model-context blocks (property + association selection)
|
||||
api/
|
||||
hubspot-api.ts CRM v3 request shaping + response parsing (getObject, getAssociations, …)
|
||||
write-back.ts Note / Task engagement payload assembly (default association edges)
|
||||
index.ts the pure public surface (what the serverless functions import)
|
||||
app/
|
||||
public-app.json the public app: name, OAuth (scopes + redirect), card reference
|
||||
extensions/
|
||||
hanzo-assistant-card.json the CRM card config (crm.record.tab, objectTypes)
|
||||
HanzoAssistant.tsx the React UI extension
|
||||
app.functions/
|
||||
serverless.json function → file + secrets map
|
||||
hanzo-assistant.js reads record, runs the model (holds HANZO_API_KEY)
|
||||
hanzo-writeback.js creates the Note / Task engagement
|
||||
hsproject.json project name, srcDir, platformVersion (2025.1)
|
||||
assets/ app-listing icons (16/32/128)
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a HubSpot developer account + app
|
||||
|
||||
1. Sign up for a free **HubSpot developer account** at
|
||||
<https://developers.hubspot.com/> and create a **test account** (sandbox) to
|
||||
install into.
|
||||
2. Install the HubSpot CLI and authenticate:
|
||||
|
||||
```bash
|
||||
npm i -g @hubspot/cli
|
||||
hs init # writes hubspot.config.yml, authenticates an account
|
||||
hs account list # confirm your test account is connected
|
||||
```
|
||||
|
||||
### 2. Configure OAuth
|
||||
|
||||
The app declares OAuth in `src/app/public-app.json`:
|
||||
|
||||
- **Redirect URL** — `https://hubspot.hanzo.ai/oauth/callback`. Change this to
|
||||
your own HTTPS callback that runs the token exchange (`src/oauth.ts`
|
||||
`tokenExchange` → `parseTokenResponse`).
|
||||
- **Required scopes** — read + write on the three object types the card mounts
|
||||
on:
|
||||
|
||||
```
|
||||
crm.objects.contacts.read crm.objects.contacts.write
|
||||
crm.objects.companies.read crm.objects.companies.write
|
||||
crm.objects.deals.read crm.objects.deals.write
|
||||
```
|
||||
|
||||
Read powers **Summarize / Draft / Next steps / Ask**; write powers the
|
||||
**Note / Task** write-back.
|
||||
|
||||
The install flow: send the user to `authorizeUrl(...)` (built in `src/oauth.ts`),
|
||||
HubSpot redirects back to your callback with `?code=…&state=…`, you `POST` the
|
||||
`tokenExchange` request to `https://api.hubapi.com/oauth/v1/token`, and store the
|
||||
returned token set. HubSpot then makes the per-portal token available to the
|
||||
serverless functions.
|
||||
|
||||
### 3. Set the Hanzo key as a secret
|
||||
|
||||
The model key never goes in source or in the browser — it is a HubSpot secret:
|
||||
|
||||
```bash
|
||||
hs secret add HANZO_API_KEY # paste your hk-… key from hanzo.id
|
||||
```
|
||||
|
||||
`serverless.json` declares `HANZO_API_KEY` on the `hanzo-assistant` function, so
|
||||
HubSpot injects it as `process.env.HANZO_API_KEY` at runtime.
|
||||
|
||||
### 4. Upload + deploy
|
||||
|
||||
```bash
|
||||
cd packages/hubspot
|
||||
hs project upload # builds and uploads the project (creates a build)
|
||||
hs project deploy # deploys the latest build to make the app live
|
||||
# iterate with a live reload while developing:
|
||||
hs project dev
|
||||
```
|
||||
|
||||
`hs project dev` runs the card locally against your test account (records show a
|
||||
"Developing locally" tag). To point `hubspot.fetch`/serverless calls at a local
|
||||
backend during development, use a `local.json` proxy.
|
||||
|
||||
### 5. Install the card on record pages
|
||||
|
||||
1. Install the app into your test account from the app's install URL (the OAuth
|
||||
consent flow above).
|
||||
2. Open any **contact**, **company**, or **deal** record.
|
||||
3. The **Hanzo AI** card appears as a tab (`crm.record.tab`). Add it to the
|
||||
record's default view via **Customize record** if it isn't shown.
|
||||
|
||||
### 6. Use it
|
||||
|
||||
Pick a **model** (the picker is populated from `/v1/models` via the serverless
|
||||
proxy; defaults to `zen5`), choose an **action**, and run. For **Draft email**
|
||||
and **Ask**, type the topic / question first. Review the output, then
|
||||
**Save as Note** or **Create Task** to write it back to the record.
|
||||
|
||||
## App Marketplace
|
||||
|
||||
To list publicly on the **HubSpot App Marketplace**:
|
||||
|
||||
1. Complete the app's listing in the developer portal (name, description, the
|
||||
icons in `assets/`, screenshots, support info).
|
||||
2. Ensure the app requests only the scopes it uses (the six above) and has a
|
||||
production OAuth redirect and install flow.
|
||||
3. Submit for review at
|
||||
<https://developers.hubspot.com/docs/guides/apps/marketplace/listing-your-app>.
|
||||
Marketplace apps must use `oauth` auth (already the case here).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/hubspot test # vitest — the pure core
|
||||
pnpm --filter @hanzo/hubspot typecheck # tsc --noEmit over src + tests
|
||||
pnpm --filter @hanzo/hubspot build # emit dist/ (ESM + CJS) for the serverless import
|
||||
```
|
||||
|
||||
The tests are pure (no network): CRM request shaping + parsing, OAuth
|
||||
token-exchange shaping, record-context assembly/truncation, the `@hanzo/ai`
|
||||
request shaping (over an injected client), and the Note/Task write-back payloads.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 452 B |
Binary file not shown.
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "hanzo-ai-for-hubspot",
|
||||
"srcDir": "src",
|
||||
"platformVersion": "2025.1"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@hanzo/hubspot",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI for HubSpot — AI over your CRM records: summarize a contact/company/deal, draft an email, suggest next steps, and ask anything, with one-click write-back as a Note or Task. A CRM card UI extension plus a serverless backend that holds the Hanzo key and calls @hanzo/ai over the api.hanzo.ai gateway; the key never reaches the browser.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/cjs/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.0",
|
||||
"@hanzo/iam": "^0.13.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hubspot/ui-extensions": ">=0.15.0",
|
||||
"react": ">=18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hubspot/ui-extensions": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hubspot/ui-extensions": "0.15.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"react": "18.3.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"hubspot",
|
||||
"crm",
|
||||
"ui-extensions",
|
||||
"ai",
|
||||
"sales",
|
||||
"marketing"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/hubspot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// HubSpot CRM v3 REST API — pure request shaping + response parsing. Every
|
||||
// function returns a PreparedCrmRequest (method + url + headers + optional body) or
|
||||
// parses a response body; none opens a socket, so the whole API surface is
|
||||
// unit-testable without a network. The serverless function (src/app/app.functions)
|
||||
// is the thin glue that fetches these shapes with the per-portal OAuth access
|
||||
// token HubSpot injects.
|
||||
//
|
||||
// The REST root is `${base}/crm/v3` (see config.crmApiRoot). All paths below are
|
||||
// built on top of that. Docs:
|
||||
// developers.hubspot.com/docs/reference/api/crm/objects.
|
||||
|
||||
import { crmApiRoot, type ObjectType } from '../config.js';
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Bearer-only — the
|
||||
// CRM API is authorized by the OAuth access token. `body` is a JSON string when
|
||||
// present (POST/PATCH), absent for GET. Pure output so a test asserts the exact
|
||||
// method/url/headers/body without opening a socket.
|
||||
export interface PreparedCrmRequest {
|
||||
method: 'GET' | 'POST' | 'PATCH';
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
// bearer builds the Authorization + Content-Type headers for a CRM request. The
|
||||
// access token is the only credential the CRM API needs.
|
||||
function bearer(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
// enc encodes a single path segment. Object ids are numeric strings and types
|
||||
// are a fixed vocabulary, but we encode defensively at this boundary so a stray
|
||||
// character can never break the path (or worse, traverse it).
|
||||
function enc(segment: string): string {
|
||||
return encodeURIComponent(segment);
|
||||
}
|
||||
|
||||
// getObject — one CRM record with the requested properties. `properties` is
|
||||
// joined into the comma-separated `properties` query param HubSpot expects; when
|
||||
// empty, HubSpot returns its default property set. Used to read the record the
|
||||
// card is mounted on before summarizing/drafting.
|
||||
export function getObject(
|
||||
root: string,
|
||||
objectType: ObjectType,
|
||||
objectId: string,
|
||||
accessToken: string,
|
||||
properties: readonly string[] = [],
|
||||
): PreparedCrmRequest {
|
||||
const q = new URLSearchParams();
|
||||
if (properties.length > 0) q.set('properties', properties.join(','));
|
||||
const query = q.toString();
|
||||
return {
|
||||
method: 'GET',
|
||||
url: `${root}/objects/${enc(objectType)}/${enc(objectId)}${query ? `?${query}` : ''}`,
|
||||
headers: bearer(accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getAssociations — the ids of records of `toObjectType` associated with one
|
||||
// record. Used to pull a contact's deals/companies (and vice versa) so the model
|
||||
// sees the record in context, not in isolation. HubSpot caps a page at 500 and
|
||||
// paginates with `after`; the default limit is generous for a card summary.
|
||||
export function getAssociations(
|
||||
root: string,
|
||||
fromObjectType: ObjectType,
|
||||
objectId: string,
|
||||
toObjectType: ObjectType,
|
||||
accessToken: string,
|
||||
limit = 100,
|
||||
): PreparedCrmRequest {
|
||||
const q = new URLSearchParams({ limit: String(limit) });
|
||||
return {
|
||||
method: 'GET',
|
||||
url: `${root}/objects/${enc(fromObjectType)}/${enc(objectId)}/associations/${enc(toObjectType)}?${q.toString()}`,
|
||||
headers: bearer(accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// batchReadObjects — read many records of one type by id in a single call, with
|
||||
// the requested properties. Used to expand association ids (from getAssociations)
|
||||
// into readable rows for the context, without one request per record.
|
||||
export function batchReadObjects(
|
||||
root: string,
|
||||
objectType: ObjectType,
|
||||
ids: readonly string[],
|
||||
accessToken: string,
|
||||
properties: readonly string[] = [],
|
||||
): PreparedCrmRequest {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${root}/objects/${enc(objectType)}/batch/read`,
|
||||
headers: bearer(accessToken),
|
||||
body: JSON.stringify({
|
||||
properties: [...properties],
|
||||
inputs: ids.map((id) => ({ id })),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Write-back: engagements (Note / Task) ──────────────────────────────────
|
||||
//
|
||||
// The write-back is an engagement created on the record. HubSpot models Notes
|
||||
// and Tasks as first-class CRM objects (`/objects/notes`, `/objects/tasks`) with
|
||||
// their own required timestamp property; the association to the record is passed
|
||||
// inline in the create body via the default association type. See
|
||||
// noteWriteBack / taskWriteBack in write-back.ts for the payload assembly.
|
||||
|
||||
// createNote — POST a Note engagement. `body` is the fully-formed create payload
|
||||
// (properties + associations) from write-back.noteWriteBack. Kept as pure
|
||||
// request-shaping so the payload assembly is tested independently of the wire.
|
||||
export function createNote(
|
||||
root: string,
|
||||
payload: unknown,
|
||||
accessToken: string,
|
||||
): PreparedCrmRequest {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${root}/objects/notes`,
|
||||
headers: bearer(accessToken),
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
// createTask — POST a Task engagement. Same shape as createNote against the
|
||||
// `tasks` object.
|
||||
export function createTask(
|
||||
root: string,
|
||||
payload: unknown,
|
||||
accessToken: string,
|
||||
): PreparedCrmRequest {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${root}/objects/tasks`,
|
||||
headers: bearer(accessToken),
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Response parsing ───────────────────────────────────────────────────────
|
||||
|
||||
// One CRM record as it appears in a getObject / batch-read response.
|
||||
export interface CrmObject {
|
||||
id: string;
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// parseObject reads a getObject body into a typed record. Missing/undefined
|
||||
// property values become '' (never undefined) so the context assembly renders
|
||||
// predictably. Pure — unit-tested with plain objects.
|
||||
export function parseObject(data: unknown): CrmObject {
|
||||
const d = data as { id?: unknown; properties?: Record<string, unknown> } | null;
|
||||
const rawProps = d && typeof d.properties === 'object' && d.properties ? d.properties : {};
|
||||
const properties: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(rawProps)) {
|
||||
properties[k] = v == null ? '' : String(v);
|
||||
}
|
||||
return { id: String(d?.id ?? ''), properties };
|
||||
}
|
||||
|
||||
// parseObjects reads a batch-read body (`{ results: [...] }`) into a typed list,
|
||||
// dropping any element with no id. Pure.
|
||||
export function parseObjects(data: unknown): CrmObject[] {
|
||||
const results = (data as { results?: unknown[] } | null)?.results;
|
||||
const rows = Array.isArray(results) ? results : [];
|
||||
return rows.map(parseObject).filter((o) => o.id.length > 0);
|
||||
}
|
||||
|
||||
// parseAssociationIds reads a getAssociations body (`{ results: [{ toObjectId }]
|
||||
// | [{ id }] }`) into the list of associated record ids. HubSpot has returned
|
||||
// both `toObjectId` (v4 associations) and `id` (v3) shapes across the CRM API;
|
||||
// we accept either at this boundary so a version shift can't silently drop
|
||||
// associations. Pure — unit-tested.
|
||||
export function parseAssociationIds(data: unknown): string[] {
|
||||
const results = (data as { results?: unknown[] } | null)?.results;
|
||||
const rows = Array.isArray(results) ? results : [];
|
||||
const ids: string[] = [];
|
||||
for (const r of rows) {
|
||||
const o = r as { toObjectId?: unknown; id?: unknown };
|
||||
const raw = o?.toObjectId ?? o?.id;
|
||||
if (raw != null && String(raw).length > 0) ids.push(String(raw));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Engagement write-back payload assembly — pure. Turns AI output into the exact
|
||||
// create body for a HubSpot Note or Task engagement, associated to the record
|
||||
// the card is mounted on. No I/O: the payloads produced here are handed to
|
||||
// api/hubspot-api.createNote / createTask for the actual POST, so the payload
|
||||
// shape (the load-bearing part) is unit-tested independently of the wire.
|
||||
//
|
||||
// HubSpot models Notes and Tasks as CRM objects. Creating one WITH an inline
|
||||
// association uses the default HUBSPOT_DEFINED association type for the
|
||||
// (engagement → record) direction. The type ids are HubSpot's documented
|
||||
// defaults per object type; unidirectional (note→contact ≠ contact→note).
|
||||
|
||||
import type { ObjectType } from '../config.js';
|
||||
|
||||
// Default HUBSPOT_DEFINED association type ids, engagement → record. These are
|
||||
// HubSpot's stable defaults (developers.hubspot.com association type reference).
|
||||
// A portal can define custom labels, but the DEFAULT edge is what a fresh
|
||||
// association uses and what the CRM UI shows on the record timeline.
|
||||
export const NOTE_ASSOCIATION_TYPE_ID: Record<ObjectType, number> = {
|
||||
contacts: 202,
|
||||
companies: 190,
|
||||
deals: 214,
|
||||
};
|
||||
export const TASK_ASSOCIATION_TYPE_ID: Record<ObjectType, number> = {
|
||||
contacts: 204,
|
||||
companies: 192,
|
||||
deals: 216,
|
||||
};
|
||||
|
||||
// The HubSpot association-category discriminator. We only ever create the
|
||||
// platform's built-in edges, so this is always HUBSPOT_DEFINED.
|
||||
export const ASSOCIATION_CATEGORY = 'HUBSPOT_DEFINED' as const;
|
||||
|
||||
// One inline association in a create body. `to.id` is the record this engagement
|
||||
// attaches to; `types[]` is the single default edge.
|
||||
interface AssociationSpec {
|
||||
to: { id: string };
|
||||
types: Array<{ associationCategory: typeof ASSOCIATION_CATEGORY; associationTypeId: number }>;
|
||||
}
|
||||
|
||||
// association builds the inline association block for an engagement→record edge.
|
||||
function association(objectId: string, typeId: number): AssociationSpec {
|
||||
return {
|
||||
to: { id: objectId },
|
||||
types: [{ associationCategory: ASSOCIATION_CATEGORY, associationTypeId: typeId }],
|
||||
};
|
||||
}
|
||||
|
||||
// The Note create payload.
|
||||
export interface NoteWriteBack {
|
||||
properties: {
|
||||
hs_note_body: string;
|
||||
hs_timestamp: string;
|
||||
};
|
||||
associations: AssociationSpec[];
|
||||
}
|
||||
|
||||
// The Task create payload. hs_task_status/priority default to a sensible open,
|
||||
// not-urgent task; hs_task_subject is the one-line title, hs_task_body the
|
||||
// detail. hs_timestamp on a task is its DUE date.
|
||||
export interface TaskWriteBack {
|
||||
properties: {
|
||||
hs_task_subject: string;
|
||||
hs_task_body: string;
|
||||
hs_task_status: string;
|
||||
hs_task_priority: string;
|
||||
hs_timestamp: string;
|
||||
};
|
||||
associations: AssociationSpec[];
|
||||
}
|
||||
|
||||
// nowIso returns the current time as the ISO-8601 string HubSpot's timestamp
|
||||
// properties accept. Isolated so tests inject a fixed clock (nowIso is only
|
||||
// called when the caller doesn't pass an explicit timestamp).
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// noteWriteBack assembles the create payload for a Note carrying AI output,
|
||||
// associated to the current record. `timestamp` defaults to now (a note's
|
||||
// timestamp is when it was logged). Pure given an explicit timestamp — the one
|
||||
// unit under test. The body is HTML-capable in HubSpot; we pass the model's text
|
||||
// through unchanged (the caller decides formatting) and never inject markup.
|
||||
export function noteWriteBack(
|
||||
objectType: ObjectType,
|
||||
objectId: string,
|
||||
body: string,
|
||||
timestamp: string = nowIso(),
|
||||
): NoteWriteBack {
|
||||
return {
|
||||
properties: {
|
||||
hs_note_body: body,
|
||||
hs_timestamp: timestamp,
|
||||
},
|
||||
associations: [association(objectId, NOTE_ASSOCIATION_TYPE_ID[objectType])],
|
||||
};
|
||||
}
|
||||
|
||||
// taskWriteBack assembles the create payload for a follow-up Task, associated to
|
||||
// the current record. subject is the title (required, non-empty); body is the
|
||||
// detail. `dueTimestamp` defaults to now (surfaces immediately on the task
|
||||
// queue); a caller schedules it forward by passing a future ISO string. Pure
|
||||
// given an explicit timestamp.
|
||||
export function taskWriteBack(
|
||||
objectType: ObjectType,
|
||||
objectId: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
dueTimestamp: string = nowIso(),
|
||||
): TaskWriteBack {
|
||||
return {
|
||||
properties: {
|
||||
hs_task_subject: subject,
|
||||
hs_task_body: body,
|
||||
hs_task_status: 'NOT_STARTED',
|
||||
hs_task_priority: 'MEDIUM',
|
||||
hs_timestamp: dueTimestamp,
|
||||
},
|
||||
associations: [association(objectId, TASK_ASSOCIATION_TYPE_ID[objectType])],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Serverless backend for the Hanzo AI card — the ONLY place the Hanzo key lives.
|
||||
//
|
||||
// The card (HanzoAssistant.tsx) calls this with the record's type/id, the chosen
|
||||
// action, an optional detail (draft topic / question), and the chosen model. This
|
||||
// function reads the record + its associated records with the app's OAuth token,
|
||||
// assembles the model context and runs the completion through the PURE
|
||||
// @hanzo/hubspot core (record → context blocks → windowed context → @hanzo/ai),
|
||||
// and returns the text. The Hanzo key (HANZO_API_KEY secret) and the CRM token
|
||||
// never leave this backend — the browser only ever sees the final answer.
|
||||
//
|
||||
// context shape (HubSpot serverless):
|
||||
// context.propertiesToSend – CRM props the card requested (hs_object_id, etc.)
|
||||
// context.parameters – the card's payload (objectType, action, detail, model)
|
||||
// process.env.PRIVATE_APP_ACCESS_TOKEN – the app's per-portal OAuth token
|
||||
// process.env.HANZO_API_KEY – the Hanzo bearer (declared in serverless.json secrets)
|
||||
|
||||
const hubspot = require('@hubspot/api-client');
|
||||
const {
|
||||
isObjectType,
|
||||
DEFAULT_PROPERTIES,
|
||||
ASSOCIATED_TYPES,
|
||||
buildContextBlocks,
|
||||
assembleRecordContext,
|
||||
actionTask,
|
||||
complete,
|
||||
listModels,
|
||||
} = require('@hanzo/hubspot');
|
||||
|
||||
// Read the primary record + its associated records with the app's OAuth token,
|
||||
// returning parsed CrmObjects arranged for buildContextBlocks. Uses the official
|
||||
// @hubspot/api-client (no bespoke HTTP), so pagination/retries are handled.
|
||||
async function loadRecord(client, objectType, objectId) {
|
||||
const primaryResp = await client.crm.objects.basicApi.getById(
|
||||
objectType,
|
||||
objectId,
|
||||
DEFAULT_PROPERTIES[objectType],
|
||||
);
|
||||
const primary = { id: String(primaryResp.id), properties: normalizeProps(primaryResp.properties) };
|
||||
|
||||
const associations = {};
|
||||
for (const assocType of ASSOCIATED_TYPES[objectType]) {
|
||||
const idsResp = await client.crm.associations.v4.basicApi.getPage(
|
||||
objectType,
|
||||
objectId,
|
||||
assocType,
|
||||
undefined,
|
||||
50,
|
||||
);
|
||||
const ids = (idsResp.results || [])
|
||||
.map((r) => String(r.toObjectId != null ? r.toObjectId : r.id))
|
||||
.filter((id) => id.length > 0);
|
||||
if (ids.length === 0) continue;
|
||||
const batch = await client.crm.objects.batchApi.read(assocType, {
|
||||
properties: DEFAULT_PROPERTIES[assocType],
|
||||
inputs: ids.slice(0, 10).map((id) => ({ id })),
|
||||
});
|
||||
associations[assocType] = (batch.results || []).map((o) => ({
|
||||
id: String(o.id),
|
||||
properties: normalizeProps(o.properties),
|
||||
}));
|
||||
}
|
||||
return { primary, associations };
|
||||
}
|
||||
|
||||
// normalizeProps coerces every property value to a string (the shape the pure
|
||||
// core expects); null/undefined become ''.
|
||||
function normalizeProps(props) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(props || {})) out[k] = v == null ? '' : String(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
exports.main = async (context = {}) => {
|
||||
const params = context.parameters || {};
|
||||
const objectType = params.objectType;
|
||||
const action = params.action || 'summarize';
|
||||
const detail = params.detail || '';
|
||||
const model = params.model || undefined;
|
||||
const hanzoKey = process.env.HANZO_API_KEY || '';
|
||||
|
||||
// The model-catalog request powers the card's picker. It needs neither a record
|
||||
// nor the CRM token — only the Hanzo bearer — so it short-circuits here.
|
||||
if (action === 'models') {
|
||||
try {
|
||||
return { models: await listModels({ token: hanzoKey }) };
|
||||
} catch (error) {
|
||||
return { error: (error && error.message) || 'Unable to list models' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!isObjectType(objectType)) {
|
||||
return { error: `Unsupported object type: ${objectType}` };
|
||||
}
|
||||
const objectId = String((context.propertiesToSend || {}).hs_object_id || params.objectId || '');
|
||||
if (!objectId) {
|
||||
return { error: 'Missing record id (hs_object_id).' };
|
||||
}
|
||||
const crmToken = process.env.PRIVATE_APP_ACCESS_TOKEN;
|
||||
if (!crmToken) {
|
||||
return { error: 'Missing CRM access token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new hubspot.Client({ accessToken: crmToken });
|
||||
const { primary, associations } = await loadRecord(client, objectType, objectId);
|
||||
|
||||
const blocks = buildContextBlocks(objectType, primary, associations);
|
||||
const ctx = assembleRecordContext(blocks);
|
||||
const task = actionTask(action, detail);
|
||||
const text = await complete(task, ctx, { token: hanzoKey, model });
|
||||
|
||||
return { text, truncated: ctx.truncated };
|
||||
} catch (error) {
|
||||
console.error('hanzo-assistant error:', error && error.message, error && error.stack);
|
||||
return { error: (error && error.message) || 'Unknown error' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
// Serverless backend for write-back — creates a Note or Task engagement on the
|
||||
// record, associated via the default HUBSPOT_DEFINED edge. The card calls this
|
||||
// after the user reviews an AI result and clicks "Save as Note" / "Create Task".
|
||||
//
|
||||
// The payload assembly (properties + associations) is the PURE @hanzo/hubspot
|
||||
// core (noteWriteBack / taskWriteBack), so the exact create body is unit-tested;
|
||||
// this function is the thin async edge that POSTs it with the app's OAuth token.
|
||||
//
|
||||
// context.parameters: { kind: 'note' | 'task', objectType, objectId, body,
|
||||
// subject? (task title), dueTimestamp? (task due, ISO) }
|
||||
// process.env.PRIVATE_APP_ACCESS_TOKEN – the app's per-portal OAuth token
|
||||
|
||||
const hubspot = require('@hubspot/api-client');
|
||||
const { isObjectType, noteWriteBack, taskWriteBack } = require('@hanzo/hubspot');
|
||||
|
||||
exports.main = async (context = {}) => {
|
||||
const params = context.parameters || {};
|
||||
const kind = params.kind || 'note';
|
||||
const objectType = params.objectType;
|
||||
const objectId = String(params.objectId || (context.propertiesToSend || {}).hs_object_id || '');
|
||||
const body = String(params.body || '');
|
||||
|
||||
if (!isObjectType(objectType)) return { error: `Unsupported object type: ${objectType}` };
|
||||
if (!objectId) return { error: 'Missing record id.' };
|
||||
if (!body.trim()) return { error: 'Nothing to write back (empty body).' };
|
||||
|
||||
const crmToken = process.env.PRIVATE_APP_ACCESS_TOKEN;
|
||||
if (!crmToken) return { error: 'Missing CRM access token.' };
|
||||
|
||||
try {
|
||||
const client = new hubspot.Client({ accessToken: crmToken });
|
||||
if (kind === 'task') {
|
||||
const subject = String(params.subject || 'Follow up (Hanzo AI)');
|
||||
const payload = taskWriteBack(objectType, objectId, subject, body, params.dueTimestamp);
|
||||
const created = await client.crm.objects.basicApi.create('tasks', payload);
|
||||
return { id: String(created.id), kind: 'task' };
|
||||
}
|
||||
const payload = noteWriteBack(objectType, objectId, body);
|
||||
const created = await client.crm.objects.basicApi.create('notes', payload);
|
||||
return { id: String(created.id), kind: 'note' };
|
||||
} catch (error) {
|
||||
console.error('hanzo-writeback error:', error && error.message, error && error.stack);
|
||||
return { error: (error && error.message) || 'Unknown error' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "hanzo-hubspot-functions",
|
||||
"version": "0.1.0",
|
||||
"description": "Serverless backend for Hanzo AI for HubSpot — holds the Hanzo API key and calls @hanzo/ai; reads/writes CRM records with the app's OAuth token.",
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.0",
|
||||
"@hanzo/hubspot": "workspace:*",
|
||||
"@hubspot/api-client": "^11.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"appFunctions": {
|
||||
"hanzo-assistant": {
|
||||
"file": "hanzo-assistant.js",
|
||||
"secrets": ["HANZO_API_KEY"]
|
||||
},
|
||||
"hanzo-writeback": {
|
||||
"file": "hanzo-writeback.js",
|
||||
"secrets": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Hanzo AI card — the CRM record panel. Pure UI glue: it reads the record in
|
||||
// context (context.crm), lets the user pick a model + an action, calls the
|
||||
// `hanzo-assistant` serverless function (which holds the Hanzo key and does the
|
||||
// model call), renders the result, and offers one-click write-back as a Note or
|
||||
// Task via `hanzo-writeback`. No secret and no direct model call live here — the
|
||||
// browser only ever sees the final text.
|
||||
//
|
||||
// Only components exported from @hubspot/ui-extensions may be used in a card; the
|
||||
// global `window`/`fetch` is not available (all backend work goes through
|
||||
// runServerlessFunction). See app.functions/ for the two functions this calls.
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
hubspot,
|
||||
Flex,
|
||||
Text,
|
||||
Select,
|
||||
TextArea,
|
||||
Button,
|
||||
ButtonRow,
|
||||
Divider,
|
||||
Alert,
|
||||
LoadingSpinner,
|
||||
} from '@hubspot/ui-extensions';
|
||||
import type { ExtensionPointApi } from '@hubspot/ui-extensions';
|
||||
|
||||
type Api = ExtensionPointApi<'crm.record.tab'>;
|
||||
type RunServerless = Api['runServerlessFunction'];
|
||||
type Actions = Api['actions'];
|
||||
|
||||
// HubSpot's object-type ids for the standard CRM objects → our plural names (the
|
||||
// same strings the serverless functions and CRM v3 paths use). The card config
|
||||
// only mounts it on these three; an unlisted type falls back to 'contacts'.
|
||||
const OBJECT_TYPE_BY_ID: Record<string, 'contacts' | 'companies' | 'deals'> = {
|
||||
'0-1': 'contacts',
|
||||
'0-2': 'companies',
|
||||
'0-3': 'deals',
|
||||
};
|
||||
|
||||
type ActionId = 'summarize' | 'draft' | 'nextSteps' | 'ask';
|
||||
|
||||
// The four actions, in card order. `needsDetail` marks the two that take a
|
||||
// free-text input (draft topic / question) before running.
|
||||
const ACTIONS: Array<{
|
||||
id: ActionId;
|
||||
label: string;
|
||||
needsDetail: boolean;
|
||||
detailLabel?: string;
|
||||
placeholder?: string;
|
||||
}> = [
|
||||
{ id: 'summarize', label: 'Summarize this record', needsDetail: false },
|
||||
{
|
||||
id: 'draft',
|
||||
label: 'Draft email',
|
||||
needsDetail: true,
|
||||
detailLabel: 'What should the email be about? (optional)',
|
||||
placeholder: 'e.g. following up on last week’s demo',
|
||||
},
|
||||
{ id: 'nextSteps', label: 'Next steps', needsDetail: false },
|
||||
{
|
||||
id: 'ask',
|
||||
label: 'Ask',
|
||||
needsDetail: true,
|
||||
detailLabel: 'Your question',
|
||||
placeholder: 'e.g. what’s blocking this deal?',
|
||||
},
|
||||
];
|
||||
|
||||
// unwrap normalizes a serverless result into { data, error }. HubSpot returns a
|
||||
// discriminated union (status SUCCESS/ERROR); the function bodies also return an
|
||||
// `{ error }` field on a handled failure. This flattens both so the component
|
||||
// has one code path.
|
||||
function unwrap(res: Awaited<ReturnType<RunServerless>>): { data: Record<string, unknown>; error?: string } {
|
||||
if (res.status === 'ERROR') return { data: {}, error: res.message || 'Serverless error' };
|
||||
const data = (res.response ?? {}) as Record<string, unknown>;
|
||||
const err = typeof data.error === 'string' ? data.error : undefined;
|
||||
return { data, error: err };
|
||||
}
|
||||
|
||||
hubspot.extend<'crm.record.tab'>(({ context, runServerlessFunction, actions }) => (
|
||||
<HanzoAssistant context={context} runServerless={runServerlessFunction} actions={actions} />
|
||||
));
|
||||
|
||||
interface CardProps {
|
||||
context: Api['context'];
|
||||
runServerless: RunServerless;
|
||||
actions: Actions;
|
||||
}
|
||||
|
||||
const HanzoAssistant = ({ context, runServerless, actions }: CardProps) => {
|
||||
const objectType = OBJECT_TYPE_BY_ID[context.crm.objectTypeId] ?? 'contacts';
|
||||
const objectId = String(context.crm.objectId);
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [model, setModel] = useState<string>('zen5');
|
||||
const [action, setAction] = useState<ActionId>('summarize');
|
||||
const [detail, setDetail] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Load the model catalog once (from /v1/models via the serverless proxy). A
|
||||
// failure here is non-fatal — the picker just keeps the default model.
|
||||
useEffect(() => {
|
||||
runServerless({ name: 'hanzo-assistant', parameters: { objectType, action: 'models', objectId } })
|
||||
.then((res) => {
|
||||
const { data } = unwrap(res);
|
||||
const list = data.models;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const ids = list.map(String);
|
||||
setModels(ids);
|
||||
if (!ids.includes(model)) setModel(ids[0]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep default model */
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const currentAction = ACTIONS.find((a) => a.id === action)!;
|
||||
|
||||
const run = useCallback(() => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setOutput('');
|
||||
runServerless({
|
||||
name: 'hanzo-assistant',
|
||||
propertiesToSend: ['hs_object_id'],
|
||||
parameters: { objectType, objectId, action, detail, model },
|
||||
})
|
||||
.then((res) => {
|
||||
const { data, error: err } = unwrap(res);
|
||||
if (err) throw new Error(err);
|
||||
setOutput(typeof data.text === 'string' ? data.text : '');
|
||||
})
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setBusy(false));
|
||||
}, [runServerless, objectType, objectId, action, detail, model]);
|
||||
|
||||
const writeBack = useCallback(
|
||||
(kind: 'note' | 'task') => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
runServerless({
|
||||
name: 'hanzo-writeback',
|
||||
parameters: {
|
||||
kind,
|
||||
objectType,
|
||||
objectId,
|
||||
body: output,
|
||||
subject: kind === 'task' ? `Follow up: ${currentAction.label}` : null,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
const { error: err } = unwrap(res);
|
||||
if (err) throw new Error(err);
|
||||
actions.addAlert({
|
||||
title: kind === 'task' ? 'Task created' : 'Note saved',
|
||||
message: kind === 'task' ? 'A follow-up task was added to this record.' : 'The note was added to this record.',
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setBusy(false));
|
||||
},
|
||||
[runServerless, objectType, objectId, output, currentAction, actions],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="md">
|
||||
<Text variant="microcopy">
|
||||
Hanzo AI over this {objectType.replace(/s$/, '')} record. Pick an action; the model runs on
|
||||
the record and its associated records. Nothing is saved until you choose to.
|
||||
</Text>
|
||||
|
||||
{models.length > 0 && (
|
||||
<Select
|
||||
name="model"
|
||||
label="Model"
|
||||
value={model}
|
||||
options={models.map((m) => ({ label: m, value: m }))}
|
||||
onChange={(v) => setModel(String(v))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
name="action"
|
||||
label="Action"
|
||||
value={action}
|
||||
options={ACTIONS.map((a) => ({ label: a.label, value: a.id }))}
|
||||
onChange={(v) => {
|
||||
setAction(v as ActionId);
|
||||
setOutput('');
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentAction.needsDetail && (
|
||||
<TextArea
|
||||
name="detail"
|
||||
label={currentAction.detailLabel || 'Detail'}
|
||||
placeholder={currentAction.placeholder}
|
||||
value={detail}
|
||||
onChange={(v) => setDetail(String(v))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={run} disabled={busy}>
|
||||
{busy ? 'Working…' : currentAction.label}
|
||||
</Button>
|
||||
|
||||
{busy && <LoadingSpinner label="Asking Hanzo…" />}
|
||||
{error && (
|
||||
<Alert title="Something went wrong" variant="danger">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
<Flex direction="column" gap="sm">
|
||||
<Divider />
|
||||
<Text>{output}</Text>
|
||||
<ButtonRow>
|
||||
<Button onClick={() => writeBack('note')} disabled={busy}>
|
||||
Save as Note
|
||||
</Button>
|
||||
<Button onClick={() => writeBack('task')} disabled={busy}>
|
||||
Create Task
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "crm-card",
|
||||
"data": {
|
||||
"title": "Hanzo AI",
|
||||
"uid": "hanzo_ai_assistant_card",
|
||||
"location": "crm.record.tab",
|
||||
"module": {
|
||||
"file": "HanzoAssistant.tsx"
|
||||
},
|
||||
"objectTypes": [
|
||||
{ "name": "contacts" },
|
||||
{ "name": "companies" },
|
||||
{ "name": "deals" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hanzo-hubspot-extensions",
|
||||
"version": "0.1.0",
|
||||
"description": "React UI extension (CRM card) frontend for Hanzo AI for HubSpot.",
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hubspot/ui-extensions": "0.15.0",
|
||||
"react": "18.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Hanzo AI for HubSpot",
|
||||
"description": "AI over your CRM records — summarize a contact, company, or deal, draft an email, suggest next steps, and ask anything, with one-click write-back as a Note or Task. Model calls route through the api.hanzo.ai gateway via a serverless function; the Hanzo key never reaches the browser.",
|
||||
"uid": "hanzo_ai_for_hubspot",
|
||||
"allowedUrls": ["https://api.hanzo.ai"],
|
||||
"auth": {
|
||||
"redirectUrls": ["https://hubspot.hanzo.ai/oauth/callback"],
|
||||
"requiredScopes": [
|
||||
"crm.objects.contacts.read",
|
||||
"crm.objects.contacts.write",
|
||||
"crm.objects.companies.read",
|
||||
"crm.objects.companies.write",
|
||||
"crm.objects.deals.read",
|
||||
"crm.objects.deals.write"
|
||||
],
|
||||
"optionalScopes": [],
|
||||
"conditionallyRequiredScopes": []
|
||||
},
|
||||
"extensions": {
|
||||
"crm": {
|
||||
"cards": [
|
||||
{
|
||||
"file": "extensions/hanzo-assistant-card.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Hanzo-for-HubSpot config — the two backends this app talks to and nothing
|
||||
// else. Hanzo (the model gateway, api.hanzo.ai/v1, reached via @hanzo/ai) and
|
||||
// HubSpot (OAuth + CRM REST, api.hubapi.com/crm/v3). No `/api/` prefix on Hanzo
|
||||
// (it is api.hanzo.ai already); HubSpot uses its literal `/crm/v3/...` and
|
||||
// `/oauth/v1/...` paths.
|
||||
//
|
||||
// Rule: secrets never live here. The HubSpot client_secret and the Hanzo API
|
||||
// key are read from the serverless-function environment (process.env), never
|
||||
// bundled into the card's React frontend.
|
||||
|
||||
// ── Hanzo model gateway ───────────────────────────────────────────────────
|
||||
|
||||
/** Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1
|
||||
* only, never an `/api/` prefix (api.hanzo.ai IS the api host). */
|
||||
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
/** Default model when a portal has not picked one. A Zen model (qwen3+); the
|
||||
* gateway routes it. Overridable per request via the card's model picker. */
|
||||
export const DEFAULT_MODEL = 'zen5';
|
||||
|
||||
/** Public IAM origin that mints Hanzo user tokens (owner-scoping via @hanzo/iam). */
|
||||
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
||||
|
||||
/** OAuth client id an inbound Hanzo token is audienced to (validation). The
|
||||
* <org>-<app> naming scheme used across the Hanzo suite. */
|
||||
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-hubspot';
|
||||
|
||||
// ── HubSpot API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** HubSpot's single global REST API origin (no regional fan-out). */
|
||||
export const HUBSPOT_API_BASE_URL = 'https://api.hubapi.com';
|
||||
|
||||
/** HubSpot's OAuth server origin (authorize page + token endpoint). */
|
||||
export const HUBSPOT_OAUTH_BASE_URL = 'https://app.hubspot.com';
|
||||
|
||||
/** The CRM REST API version segment. v3 is current; we never bump to a
|
||||
* speculative v4 — one version, forward only. */
|
||||
export const CRM_API_VERSION = 'v3';
|
||||
|
||||
/** The CRM REST API root — `${base}/crm/v3`. Every CRM call in api/ builds on
|
||||
* top of this. */
|
||||
export function crmApiRoot(base: string = HUBSPOT_API_BASE_URL): string {
|
||||
return `${base.replace(/\/+$/, '')}/crm/${CRM_API_VERSION}`;
|
||||
}
|
||||
|
||||
/** The OAuth authorize page a browser is redirected to for install/consent. */
|
||||
export function hubspotAuthorizeURL(base: string = HUBSPOT_OAUTH_BASE_URL): string {
|
||||
return `${base.replace(/\/+$/, '')}/oauth/authorize`;
|
||||
}
|
||||
|
||||
/** The OAuth token endpoint (code→token + refresh, server-side only). Note it
|
||||
* lives on the API host, not the app host. */
|
||||
export function hubspotTokenURL(base: string = HUBSPOT_API_BASE_URL): string {
|
||||
return `${base.replace(/\/+$/, '')}/oauth/v1/token`;
|
||||
}
|
||||
|
||||
// ── Record-context budget ────────────────────────────────────────────────
|
||||
|
||||
/** Record-context character budget. A record plus its associations (deals,
|
||||
* companies, recent notes) can run long; the whole thing won't fit a model
|
||||
* window and shouldn't be sent whole. This caps the characters of assembled
|
||||
* record text attached to any one request — chosen to fit comfortably inside a
|
||||
* modern context window alongside the reply. We truncate visibly, never drop
|
||||
* silently. Same "fit ordered text to a budget" contract as @hanzo/notion. */
|
||||
export const RECORD_CHAR_BUDGET = 48_000;
|
||||
|
||||
/** The CRM object types the card mounts on. HubSpot's canonical plural names —
|
||||
* the same strings used in the card config's `objectTypes` and in a CRM v3
|
||||
* path segment (`/crm/v3/objects/contacts`). */
|
||||
export const SUPPORTED_OBJECT_TYPES = ['contacts', 'companies', 'deals'] as const;
|
||||
export type ObjectType = (typeof SUPPORTED_OBJECT_TYPES)[number];
|
||||
|
||||
/** isObjectType narrows an arbitrary string to a supported ObjectType. Boundary
|
||||
* guard used before an object type reaches a CRM path. */
|
||||
export function isObjectType(v: string | undefined): v is ObjectType {
|
||||
return v === 'contacts' || v === 'companies' || v === 'deals';
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// The Hanzo call and its request/response shaping over a CRM RECORD — pure,
|
||||
// host-agnostic, and fully unit-testable (no HubSpot runtime, no DOM). The card
|
||||
// and the serverless function are the thin glue that read a record + its
|
||||
// associations and hand them here. Speaks to the Hanzo gateway through the
|
||||
// PUBLISHED `@hanzo/ai` headless client (createAiClient) — we do NOT reimplement
|
||||
// a client; the whole suite routes through one `/v1` shape.
|
||||
//
|
||||
// This module is the record-aware core: the record-context windowing/truncation
|
||||
// and prompt assembly a *CRM* surface adds on top of a generic chat call, kept
|
||||
// pure so it is tested and reused. The four AI actions (their prompt templates)
|
||||
// live below, layered on `complete`.
|
||||
|
||||
import { createAiClient, type AiClient, type ChatCompletionMessage } from '@hanzo/ai';
|
||||
import { DEFAULT_MODEL, HANZO_API_BASE_URL, RECORD_CHAR_BUDGET } from './config.js';
|
||||
|
||||
// SYSTEM_PROMPT grounds every answer in the record. It forbids inventing facts
|
||||
// not present in the record data (the failure mode that makes a CRM assistant
|
||||
// dangerous — a fabricated deal amount or a made-up contact detail), keeps the
|
||||
// tone appropriate for a sales/CS rep, and stays honest about truncated context.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, an assistant embedded in a HubSpot CRM record for a sales, ' +
|
||||
'marketing, or customer-success team. You are given a CRM record (a contact, ' +
|
||||
'company, or deal) with its properties and associated records, plus a task. ' +
|
||||
'Work ONLY from the record data provided — never invent contacts, amounts, ' +
|
||||
'dates, or history that is not in the data. Be concise, specific, and ' +
|
||||
'actionable. If the data is marked truncated and a complete answer needs the ' +
|
||||
'omitted part, say so plainly rather than guessing.';
|
||||
|
||||
// ── Record-context assembly / truncation (pure) ─────────────────────────────
|
||||
//
|
||||
// A record arrives as its own properties plus, optionally, ordered blocks of
|
||||
// associated records (its deals, its companies, recent notes). We render them in
|
||||
// order and cap the whole at a character budget — associations carry meaning in
|
||||
// order (the primary record first, then its context), so we never reorder, and
|
||||
// the record's own block always survives.
|
||||
|
||||
// One labeled block of the assembled context (the record itself, or one group of
|
||||
// associated records already rendered to text by the caller).
|
||||
export interface ContextBlock {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// The result of assembling a record's context down to what we'll attach: the
|
||||
// rendered text, and whether anything was dropped (so the prompt and card can
|
||||
// say so honestly).
|
||||
export interface RecordContext {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// renderProperties turns a record's property map into stable `key: value` lines,
|
||||
// dropping empty values (an empty property is noise, not signal) and sorting
|
||||
// keys so the rendering is deterministic (testable, and stable across calls).
|
||||
// Pure.
|
||||
export function renderProperties(properties: Record<string, string>): string {
|
||||
return Object.keys(properties)
|
||||
.sort()
|
||||
.map((k) => [k, properties[k]] as const)
|
||||
.filter(([, v]) => v.trim().length > 0)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// assembleRecordContext renders ordered blocks into the single fenced string
|
||||
// handed to Hanzo, capped at `budget` characters. It walks blocks IN ORDER and
|
||||
// stops when adding the next block would exceed the budget — always including at
|
||||
// least the first block (hard-cut to the budget if that one block is over).
|
||||
// `truncated` is true whenever not every block made it in. Pure and total:
|
||||
// deterministic, no I/O. (Same "fit ordered text to a budget" contract as
|
||||
// @hanzo/notion's page windowing — one algorithm, reused.)
|
||||
export function assembleRecordContext(
|
||||
blocks: ContextBlock[],
|
||||
budget: number = RECORD_CHAR_BUDGET,
|
||||
): RecordContext {
|
||||
if (blocks.length === 0) return { text: '', truncated: false };
|
||||
|
||||
const rendered: string[] = [];
|
||||
let used = 0;
|
||||
let hardCut = false;
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
const block = b.text.trim() ? `### ${b.label}\n${b.text.trim()}` : `### ${b.label}`;
|
||||
const addition = (rendered.length === 0 ? '' : '\n\n') + block;
|
||||
if (used + addition.length > budget) {
|
||||
if (rendered.length === 0) {
|
||||
rendered.push(block.slice(0, budget));
|
||||
used = budget;
|
||||
hardCut = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
rendered.push(addition);
|
||||
used += addition.length;
|
||||
}
|
||||
|
||||
const includedBlocks = hardCut ? 1 : rendered.length;
|
||||
const truncated = includedBlocks < blocks.length || hardCut;
|
||||
return { text: rendered.join(''), truncated };
|
||||
}
|
||||
|
||||
// contextNote is the one honest sentence prepended so the model (and, echoed in
|
||||
// the card, the user) knows the scope of what it sees. Never claim the whole
|
||||
// record context was sent when it wasn't.
|
||||
export function contextNote(ctx: RecordContext): string {
|
||||
if (ctx.text.length === 0) return 'No record data was available.';
|
||||
return ctx.truncated
|
||||
? 'Record data (truncated to fit — answer only from what is shown and say so if the omitted part is needed).'
|
||||
: 'Record data (complete).';
|
||||
}
|
||||
|
||||
// buildMessages turns a task (one of the action prompts, or a freeform question)
|
||||
// plus the assembled record context into the OpenAI-compatible message list the
|
||||
// published client sends. The record is fenced so the model treats it as data,
|
||||
// not instructions (a prompt-injection boundary), and the honest context note
|
||||
// rides inside the user turn so it is never lost. Pure — unit-tested.
|
||||
export function buildMessages(task: string, ctx: RecordContext): ChatCompletionMessage[] {
|
||||
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||
const user: ChatCompletionMessage = {
|
||||
role: 'user',
|
||||
content:
|
||||
`${contextNote(ctx)}\n\n` +
|
||||
`---- record ----\n${ctx.text}\n---- end record ----\n\n` +
|
||||
`---- task ----\n${task}`,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of a published-client ChatCompletion.
|
||||
// The gateway returns choices[0].message.content; we throw on an empty/missing
|
||||
// content so the caller surfaces a real failure rather than writing back nothing.
|
||||
// Pure — unit-tested against the published response shape.
|
||||
export function extractContent(res: { choices?: Array<{ message?: { content?: unknown } }> }): string {
|
||||
const content = res?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── The single call path ────────────────────────────────────────────────────
|
||||
|
||||
export interface CompleteOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
/** Hanzo bearer: an `hk-…` API key or IAM token. Held server-side only. */
|
||||
token?: string;
|
||||
baseURL?: string;
|
||||
/** Injected client (tests). Defaults to a real createAiClient. */
|
||||
client?: AiClient;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// complete runs ONE non-streaming completion over a record and returns the
|
||||
// answer. This is the SINGLE path from every HubSpot surface to the model — the
|
||||
// four actions and a freeform question all funnel here. Built directly on the
|
||||
// published `@hanzo/ai` createAiClient: no bespoke transport, one `/v1` shape.
|
||||
// `token` may be empty (the gateway serves anonymous/limited models); when set,
|
||||
// it is the portal's Hanzo key read from the serverless env, never the frontend.
|
||||
export async function complete(
|
||||
task: string,
|
||||
ctx: RecordContext,
|
||||
opts: CompleteOptions = {},
|
||||
): Promise<string> {
|
||||
const client =
|
||||
opts.client ??
|
||||
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL });
|
||||
const res = await client.chat.completions.create(
|
||||
{
|
||||
model: opts.model ?? DEFAULT_MODEL,
|
||||
messages: buildMessages(task, ctx),
|
||||
temperature: opts.temperature,
|
||||
stream: false,
|
||||
},
|
||||
{ signal: opts.signal },
|
||||
);
|
||||
return extractContent(res);
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models via
|
||||
// the published client. The catalog is org-scoped by the bearer; an empty token
|
||||
// lists public models. Powers the card's model picker.
|
||||
export async function listModels(opts: CompleteOptions = {}): Promise<string[]> {
|
||||
const client =
|
||||
opts.client ??
|
||||
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL });
|
||||
const models = await client.models.list({ signal: opts.signal });
|
||||
return models.map((m) => m.id);
|
||||
}
|
||||
|
||||
// ── The four action definitions (pure task strings) ─────────────────────────
|
||||
|
||||
export type ActionId = 'summarize' | 'draft' | 'nextSteps' | 'ask';
|
||||
|
||||
// actionTask maps an action id (+ optional user detail) to its task instruction.
|
||||
// `draft` and `ask` take a user-supplied detail (what to write about / the
|
||||
// question); `summarize` and `nextSteps` are fixed. Pure — unit-tested.
|
||||
export function actionTask(id: ActionId, detail = ''): string {
|
||||
switch (id) {
|
||||
case 'summarize':
|
||||
return (
|
||||
'Summarize this record for a rep about to engage. Lead with a one-line ' +
|
||||
'overview (who/what this is and where it stands), then 3–6 tight bullets ' +
|
||||
'covering the key facts, the relationship, and any open items. No preamble.'
|
||||
);
|
||||
case 'draft': {
|
||||
const about = detail.trim() || 'a friendly, professional follow-up appropriate to where this relationship stands';
|
||||
return (
|
||||
`Draft an email to this contact about ${about}. Use the record data to ` +
|
||||
'personalize it (name, company, recent activity). Keep it short, warm, and ' +
|
||||
'specific. Return ONLY the email body — no subject line unless asked, no ' +
|
||||
'preamble, no placeholders like [Name] when the data gives the real value.'
|
||||
);
|
||||
}
|
||||
case 'nextSteps':
|
||||
return (
|
||||
'Recommend the next steps for this record. Return a short prioritized list ' +
|
||||
'(3–5 items); each is a concrete action a rep can take, grounded in the ' +
|
||||
'record data. No preamble.'
|
||||
);
|
||||
case 'ask':
|
||||
return detail.trim() || 'What should I know about this record before reaching out?';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// @hanzo/hubspot — public surface. The pure, host-agnostic core that the
|
||||
// HubSpot serverless function and card compose: config, the Hanzo call over a
|
||||
// CRM record (built on the published @hanzo/ai), the HubSpot CRM API request
|
||||
// shaping, engagement write-back, OAuth token exchange, and record→context
|
||||
// assembly. No I/O lives here — every export is unit-tested without a network.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './hanzo.js';
|
||||
export * from './oauth.js';
|
||||
export * from './record.js';
|
||||
export * from './api/hubspot-api.js';
|
||||
export * from './api/write-back.js';
|
||||
@@ -0,0 +1,130 @@
|
||||
// HubSpot OAuth 2.0 (Authorization Code Grant) — pure request shaping. The
|
||||
// client_secret is held by the app's backend and never reaches the card's React
|
||||
// frontend; these functions build the exact URL / headers / body of each OAuth
|
||||
// call so the wire shape is unit-testable without a network round-trip. The
|
||||
// token exchange and refresh are each one fetch in the OAuth handler.
|
||||
//
|
||||
// HubSpot puts client_id, client_secret, redirect_uri, and code all in the
|
||||
// form-encoded body of the token endpoint (POST /oauth/v1/token) — the plain
|
||||
// OAuth2 body shape, NOT HTTP Basic. Docs:
|
||||
// developers.hubspot.com/docs/guides/apps/authentication/working-with-oauth.
|
||||
|
||||
import { hubspotAuthorizeURL, hubspotTokenURL } from './config.js';
|
||||
|
||||
// authorizeUrl is where the install flow sends the user's browser to grant the
|
||||
// app. `scope` is space-joined (the OAuth2 convention), `state` is the CSRF
|
||||
// token the backend generates and re-checks on callback. `optionalScope`, when
|
||||
// present, lets the user grant extra scopes without them being required.
|
||||
export function authorizeUrl(args: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes: readonly string[];
|
||||
state: string;
|
||||
optionalScopes?: readonly string[];
|
||||
}): string {
|
||||
const q = new URLSearchParams({
|
||||
client_id: args.clientId,
|
||||
redirect_uri: args.redirectUri,
|
||||
scope: args.scopes.join(' '),
|
||||
state: args.state,
|
||||
});
|
||||
if (args.optionalScopes && args.optionalScopes.length > 0) {
|
||||
q.set('optional_scope', args.optionalScopes.join(' '));
|
||||
}
|
||||
return `${hubspotAuthorizeURL()}?${q.toString()}`;
|
||||
}
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
|
||||
// test asserts the form body + content type without opening a socket. The secret
|
||||
// rides in the body per HubSpot's OAuth2 shape, never in the URL.
|
||||
export interface PreparedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const FORM_HEADERS = { 'Content-Type': 'application/x-www-form-urlencoded' } as const;
|
||||
|
||||
// tokenExchange builds the code→token request against /oauth/v1/token.
|
||||
// grant_type=authorization_code + the code + the SAME redirect_uri used in the
|
||||
// authorize step (HubSpot requires the match) + the client credentials.
|
||||
export function tokenExchange(args: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
code: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: hubspotTokenURL(),
|
||||
headers: { ...FORM_HEADERS },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: args.clientId,
|
||||
client_secret: args.clientSecret,
|
||||
redirect_uri: args.redirectUri,
|
||||
code: args.code,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// refreshExchange builds the refresh-token→token request. Same endpoint,
|
||||
// grant_type=refresh_token. A long-lived install refreshes the portal's stored
|
||||
// token this way rather than re-consenting; redirect_uri is NOT part of a
|
||||
// refresh body (only the initial exchange needs it).
|
||||
export function refreshExchange(args: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: hubspotTokenURL(),
|
||||
headers: { ...FORM_HEADERS },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: args.clientId,
|
||||
client_secret: args.clientSecret,
|
||||
refresh_token: args.refreshToken,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// The token response we care about. HubSpot returns access_token (+
|
||||
// refresh_token, expires_in, token_type). parseTokenResponse validates the one
|
||||
// field we must have and surfaces HubSpot's own error otherwise.
|
||||
export interface HubspotTokenSet {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
// parseTokenResponse reads a /oauth/v1/token body into a typed token set. Throws
|
||||
// on an error payload (HubSpot returns { status: 'error', message }) or a
|
||||
// missing access_token, so the caller learns immediately rather than storing an
|
||||
// empty credential. Pure — unit-tested.
|
||||
export function parseTokenResponse(data: unknown): HubspotTokenSet {
|
||||
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
||||
const d = data as {
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
token_type?: unknown;
|
||||
};
|
||||
if (d.status === 'error' || d.error) {
|
||||
const reason = d.message || d.error_description || d.error || 'unknown error';
|
||||
throw new Error(`HubSpot OAuth error: ${reason}`);
|
||||
}
|
||||
if (typeof d.access_token !== 'string' || d.access_token.length === 0) {
|
||||
throw new Error('HubSpot OAuth response missing access_token');
|
||||
}
|
||||
return {
|
||||
access_token: d.access_token,
|
||||
refresh_token: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
expires_in: typeof d.expires_in === 'number' ? d.expires_in : undefined,
|
||||
token_type: typeof d.token_type === 'string' ? d.token_type : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Record → context blocks — the pure bridge between the HubSpot CRM shapes
|
||||
// (api/hubspot-api.CrmObject) and the model context (hanzo.ContextBlock[]). It
|
||||
// decides WHICH properties to request per object type and HOW to order the
|
||||
// primary record and its associated records into labeled blocks. No I/O: the
|
||||
// serverless function fetches the objects, this arranges them, and hanzo
|
||||
// windows+sends them. Kept separate so property selection and block ordering are
|
||||
// tested without a network.
|
||||
|
||||
import type { CrmObject } from './api/hubspot-api.js';
|
||||
import { renderProperties, type ContextBlock } from './hanzo.js';
|
||||
import type { ObjectType } from './config.js';
|
||||
|
||||
// The properties we request per object type — the human-meaningful fields a rep
|
||||
// cares about, not every internal HubSpot field. Requesting a curated set keeps
|
||||
// the context focused (and the request small). hs_object_id is always present;
|
||||
// we don't list it here because getObject returns the id separately.
|
||||
export const DEFAULT_PROPERTIES: Record<ObjectType, readonly string[]> = {
|
||||
contacts: [
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
'phone',
|
||||
'jobtitle',
|
||||
'company',
|
||||
'lifecyclestage',
|
||||
'hs_lead_status',
|
||||
'notes_last_updated',
|
||||
],
|
||||
companies: [
|
||||
'name',
|
||||
'domain',
|
||||
'industry',
|
||||
'phone',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'numberofemployees',
|
||||
'annualrevenue',
|
||||
'lifecyclestage',
|
||||
],
|
||||
deals: [
|
||||
'dealname',
|
||||
'amount',
|
||||
'dealstage',
|
||||
'pipeline',
|
||||
'closedate',
|
||||
'dealtype',
|
||||
'hs_priority',
|
||||
],
|
||||
};
|
||||
|
||||
// The associated object types we pull for a given primary type — the context a
|
||||
// rep would want alongside the record. A contact's companies + deals; a company's
|
||||
// contacts + deals; a deal's contacts + companies. Ordered: the closest business
|
||||
// context first. Empty groups (no associations) are simply omitted downstream.
|
||||
export const ASSOCIATED_TYPES: Record<ObjectType, readonly ObjectType[]> = {
|
||||
contacts: ['companies', 'deals'],
|
||||
companies: ['contacts', 'deals'],
|
||||
deals: ['contacts', 'companies'],
|
||||
};
|
||||
|
||||
// A short human label for an object type, singular where it reads better as a
|
||||
// block heading. Pure.
|
||||
export function typeLabel(type: ObjectType, plural: boolean): string {
|
||||
const singular: Record<ObjectType, string> = {
|
||||
contacts: 'Contact',
|
||||
companies: 'Company',
|
||||
deals: 'Deal',
|
||||
};
|
||||
const pluralMap: Record<ObjectType, string> = {
|
||||
contacts: 'Contacts',
|
||||
companies: 'Companies',
|
||||
deals: 'Deals',
|
||||
};
|
||||
return plural ? pluralMap[type] : singular[type];
|
||||
}
|
||||
|
||||
// renderObjectList turns a list of associated records of one type into one text
|
||||
// block: each record's properties rendered, separated by a rule. Capped at
|
||||
// `max` records so a contact with 200 deals doesn't dominate the context; the
|
||||
// caller's budget windowing is the final guard, this is a courtesy cap on
|
||||
// breadth. Pure.
|
||||
export function renderObjectList(objects: CrmObject[], max = 10): string {
|
||||
return objects
|
||||
.slice(0, max)
|
||||
.map((o) => renderProperties(o.properties))
|
||||
.filter((s) => s.length > 0)
|
||||
.join('\n---\n');
|
||||
}
|
||||
|
||||
// buildContextBlocks arranges the primary record and its association groups into
|
||||
// the ordered ContextBlock[] the model context is assembled from. The primary
|
||||
// record is ALWAYS first (so it survives budget truncation); each non-empty
|
||||
// association group follows in ASSOCIATED_TYPES order. Pure — unit-tested with
|
||||
// plain CrmObjects.
|
||||
export function buildContextBlocks(
|
||||
primaryType: ObjectType,
|
||||
primary: CrmObject,
|
||||
associations: Partial<Record<ObjectType, CrmObject[]>>,
|
||||
): ContextBlock[] {
|
||||
const blocks: ContextBlock[] = [
|
||||
{ label: typeLabel(primaryType, false), text: renderProperties(primary.properties) },
|
||||
];
|
||||
for (const assocType of ASSOCIATED_TYPES[primaryType]) {
|
||||
const list = associations[assocType];
|
||||
if (list && list.length > 0) {
|
||||
blocks.push({
|
||||
label: `Associated ${typeLabel(assocType, true)}`,
|
||||
text: renderObjectList(list),
|
||||
});
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HANZO_API_BASE_URL,
|
||||
DEFAULT_MODEL,
|
||||
crmApiRoot,
|
||||
hubspotAuthorizeURL,
|
||||
hubspotTokenURL,
|
||||
isObjectType,
|
||||
SUPPORTED_OBJECT_TYPES,
|
||||
RECORD_CHAR_BUDGET,
|
||||
} from '../src/config.js';
|
||||
|
||||
describe('Hanzo gateway config', () => {
|
||||
it('points at api.hanzo.ai with no /api/ prefix', () => {
|
||||
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(HANZO_API_BASE_URL).not.toContain('/api/');
|
||||
});
|
||||
it('defaults to a zen model', () => {
|
||||
expect(DEFAULT_MODEL).toMatch(/^zen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HubSpot endpoints', () => {
|
||||
it('crmApiRoot is /crm/v3 on the api host', () => {
|
||||
expect(crmApiRoot()).toBe('https://api.hubapi.com/crm/v3');
|
||||
});
|
||||
it('authorize is on the app host, token is on the api host', () => {
|
||||
expect(hubspotAuthorizeURL()).toBe('https://app.hubspot.com/oauth/authorize');
|
||||
expect(hubspotTokenURL()).toBe('https://api.hubapi.com/oauth/v1/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isObjectType', () => {
|
||||
it('accepts the three supported types', () => {
|
||||
for (const t of SUPPORTED_OBJECT_TYPES) expect(isObjectType(t)).toBe(true);
|
||||
});
|
||||
it('rejects anything else', () => {
|
||||
expect(isObjectType('tickets')).toBe(false);
|
||||
expect(isObjectType(undefined)).toBe(false);
|
||||
expect(isObjectType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECORD_CHAR_BUDGET', () => {
|
||||
it('is a sane positive cap', () => {
|
||||
expect(RECORD_CHAR_BUDGET).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AiClient } from '@hanzo/ai';
|
||||
import {
|
||||
renderProperties,
|
||||
assembleRecordContext,
|
||||
contextNote,
|
||||
buildMessages,
|
||||
extractContent,
|
||||
complete,
|
||||
listModels,
|
||||
actionTask,
|
||||
SYSTEM_PROMPT,
|
||||
type ContextBlock,
|
||||
} from '../src/hanzo.js';
|
||||
|
||||
describe('renderProperties', () => {
|
||||
it('renders sorted key: value lines, dropping empties', () => {
|
||||
const s = renderProperties({ email: 'a@b.com', firstname: 'Ada', middlename: '', lastname: 'L' });
|
||||
expect(s).toBe('email: a@b.com\nfirstname: Ada\nlastname: L');
|
||||
});
|
||||
it('is deterministic regardless of key insertion order', () => {
|
||||
const a = renderProperties({ b: '2', a: '1' });
|
||||
const b = renderProperties({ a: '1', b: '2' });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleRecordContext', () => {
|
||||
const blocks: ContextBlock[] = [
|
||||
{ label: 'Contact', text: 'firstname: Ada' },
|
||||
{ label: 'Associated Deals', text: 'dealname: Big' },
|
||||
];
|
||||
|
||||
it('renders all blocks under a budget, not truncated', () => {
|
||||
const ctx = assembleRecordContext(blocks, 10_000);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text).toContain('### Contact');
|
||||
expect(ctx.text).toContain('### Associated Deals');
|
||||
});
|
||||
|
||||
it('preserves block order', () => {
|
||||
const ctx = assembleRecordContext(blocks, 10_000);
|
||||
expect(ctx.text.indexOf('### Contact')).toBeLessThan(ctx.text.indexOf('### Associated Deals'));
|
||||
});
|
||||
|
||||
it('drops later blocks past the budget and marks truncated', () => {
|
||||
// Budget fits the first block but not the second.
|
||||
const ctx = assembleRecordContext(blocks, 25);
|
||||
expect(ctx.text).toContain('### Contact');
|
||||
expect(ctx.text).not.toContain('### Associated Deals');
|
||||
expect(ctx.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('always includes at least the first block, hard-cut if it alone is over', () => {
|
||||
const big: ContextBlock[] = [{ label: 'X', text: 'y'.repeat(1000) }];
|
||||
const ctx = assembleRecordContext(big, 50);
|
||||
expect(ctx.text.length).toBe(50);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty, not-truncated for no blocks', () => {
|
||||
expect(assembleRecordContext([])).toEqual({ text: '', truncated: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextNote', () => {
|
||||
it('reports complete vs truncated vs empty honestly', () => {
|
||||
expect(contextNote({ text: 'x', truncated: false })).toMatch(/complete/);
|
||||
expect(contextNote({ text: 'x', truncated: true })).toMatch(/truncated/);
|
||||
expect(contextNote({ text: '', truncated: false })).toMatch(/No record data/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages', () => {
|
||||
it('produces a system + user pair with the record fenced as data', () => {
|
||||
const msgs = buildMessages('Summarize', { text: 'firstname: Ada', truncated: false });
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT });
|
||||
expect(msgs[1].role).toBe('user');
|
||||
const content = msgs[1].content as string;
|
||||
expect(content).toContain('---- record ----');
|
||||
expect(content).toContain('firstname: Ada');
|
||||
expect(content).toContain('---- task ----');
|
||||
expect(content).toContain('Summarize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('pulls choices[0].message.content', () => {
|
||||
expect(
|
||||
extractContent({ choices: [{ message: { content: 'the answer' } }] }),
|
||||
).toBe('the answer');
|
||||
});
|
||||
it('throws on empty content', () => {
|
||||
expect(() => extractContent({ choices: [{ message: { content: '' } }] })).toThrow(/no content/);
|
||||
});
|
||||
it('throws on a missing choices array', () => {
|
||||
expect(() => extractContent({})).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionTask', () => {
|
||||
it('is fixed for summarize / nextSteps', () => {
|
||||
expect(actionTask('summarize')).toMatch(/Summarize this record/);
|
||||
expect(actionTask('nextSteps')).toMatch(/next steps/i);
|
||||
});
|
||||
it('folds the user detail into draft and ask', () => {
|
||||
expect(actionTask('draft', 'the renewal')).toContain('the renewal');
|
||||
expect(actionTask('ask', 'who is the decision maker?')).toBe('who is the decision maker?');
|
||||
});
|
||||
it('has sensible defaults when detail is blank', () => {
|
||||
expect(actionTask('draft', '')).toMatch(/follow-up/i);
|
||||
expect(actionTask('ask', ' ')).toMatch(/before reaching out/i);
|
||||
});
|
||||
});
|
||||
|
||||
// A stub matching the published @hanzo/ai AiClient surface for the two methods
|
||||
// the app uses, so request shaping is asserted without a network.
|
||||
function stubClient(over: Partial<{
|
||||
completion: unknown;
|
||||
models: Array<{ id: string }>;
|
||||
onCreate: (params: unknown, options: unknown) => void;
|
||||
}> = {}): AiClient {
|
||||
const create = vi.fn(async (params: unknown, options: unknown) => {
|
||||
over.onCreate?.(params, options);
|
||||
return (over.completion ?? { choices: [{ message: { content: 'ok' } }] }) as never;
|
||||
});
|
||||
const list = vi.fn(async () => (over.models ?? [{ id: 'zen5' }, { id: 'zen5-pro' }]) as never);
|
||||
return {
|
||||
chat: { completions: { create } },
|
||||
models: { list },
|
||||
} as unknown as AiClient;
|
||||
}
|
||||
|
||||
describe('complete (over injected @hanzo/ai client)', () => {
|
||||
it('sends model + non-streaming messages and returns the content', async () => {
|
||||
let seen: any;
|
||||
const client = stubClient({
|
||||
completion: { choices: [{ message: { content: 'summary text' } }] },
|
||||
onCreate: (params) => (seen = params),
|
||||
});
|
||||
const out = await complete(
|
||||
'Summarize',
|
||||
{ text: 'firstname: Ada', truncated: false },
|
||||
{ client, model: 'zen5-pro', temperature: 0.2 },
|
||||
);
|
||||
expect(out).toBe('summary text');
|
||||
expect(seen.model).toBe('zen5-pro');
|
||||
expect(seen.stream).toBe(false);
|
||||
expect(seen.temperature).toBe(0.2);
|
||||
expect(seen.messages[0].role).toBe('system');
|
||||
expect(seen.messages[1].content).toContain('firstname: Ada');
|
||||
});
|
||||
|
||||
it('defaults the model when none is passed', async () => {
|
||||
let seen: any;
|
||||
const client = stubClient({ onCreate: (p) => (seen = p) });
|
||||
await complete('t', { text: 'x', truncated: false }, { client });
|
||||
expect(seen.model).toBe('zen5');
|
||||
});
|
||||
|
||||
it('forwards the abort signal in the options arg', async () => {
|
||||
let opts: any;
|
||||
const controller = new AbortController();
|
||||
const client = stubClient({ onCreate: (_p, o) => (opts = o) });
|
||||
await complete('t', { text: 'x', truncated: false }, { client, signal: controller.signal });
|
||||
expect(opts.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels (over injected @hanzo/ai client)', () => {
|
||||
it('maps the catalog to ids', async () => {
|
||||
const client = stubClient({ models: [{ id: 'zen5' }, { id: 'zen5-coder' }] });
|
||||
expect(await listModels({ client })).toEqual(['zen5', 'zen5-coder']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getObject,
|
||||
getAssociations,
|
||||
batchReadObjects,
|
||||
createNote,
|
||||
createTask,
|
||||
parseObject,
|
||||
parseObjects,
|
||||
parseAssociationIds,
|
||||
} from '../src/api/hubspot-api.js';
|
||||
import { crmApiRoot } from '../src/config.js';
|
||||
|
||||
const ROOT = crmApiRoot(); // https://api.hubapi.com/crm/v3
|
||||
const TOKEN = 'oauth-access-token';
|
||||
|
||||
describe('crmApiRoot', () => {
|
||||
it('is the v3 CRM root on the api host, no trailing slash', () => {
|
||||
expect(ROOT).toBe('https://api.hubapi.com/crm/v3');
|
||||
});
|
||||
it('strips a trailing slash from a custom base', () => {
|
||||
expect(crmApiRoot('https://example.com/')).toBe('https://example.com/crm/v3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObject', () => {
|
||||
it('shapes a GET with a comma-joined properties query', () => {
|
||||
const req = getObject(ROOT, 'contacts', '123', TOKEN, ['firstname', 'email']);
|
||||
expect(req.method).toBe('GET');
|
||||
expect(req.url).toBe(
|
||||
'https://api.hubapi.com/crm/v3/objects/contacts/123?properties=firstname%2Cemail',
|
||||
);
|
||||
expect(req.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(req.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the query entirely when no properties are requested', () => {
|
||||
const req = getObject(ROOT, 'deals', '999', TOKEN);
|
||||
expect(req.url).toBe('https://api.hubapi.com/crm/v3/objects/deals/999');
|
||||
});
|
||||
|
||||
it('url-encodes the object id defensively', () => {
|
||||
const req = getObject(ROOT, 'companies', 'a/b?c', TOKEN);
|
||||
expect(req.url).toContain('/companies/a%2Fb%3Fc');
|
||||
expect(req.url).not.toContain('a/b?c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssociations', () => {
|
||||
it('shapes the associations GET with a limit', () => {
|
||||
const req = getAssociations(ROOT, 'contacts', '123', 'deals', TOKEN, 50);
|
||||
expect(req.method).toBe('GET');
|
||||
expect(req.url).toBe(
|
||||
'https://api.hubapi.com/crm/v3/objects/contacts/123/associations/deals?limit=50',
|
||||
);
|
||||
});
|
||||
it('defaults the limit to 100', () => {
|
||||
const req = getAssociations(ROOT, 'deals', '5', 'contacts', TOKEN);
|
||||
expect(req.url).toContain('limit=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchReadObjects', () => {
|
||||
it('shapes a POST batch/read with inputs + properties', () => {
|
||||
const req = batchReadObjects(ROOT, 'deals', ['1', '2'], TOKEN, ['dealname', 'amount']);
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.url).toBe('https://api.hubapi.com/crm/v3/objects/deals/batch/read');
|
||||
expect(req.headers['Content-Type']).toBe('application/json');
|
||||
const body = JSON.parse(req.body!);
|
||||
expect(body).toEqual({
|
||||
properties: ['dealname', 'amount'],
|
||||
inputs: [{ id: '1' }, { id: '2' }],
|
||||
});
|
||||
});
|
||||
it('handles an empty id list', () => {
|
||||
const req = batchReadObjects(ROOT, 'contacts', [], TOKEN);
|
||||
expect(JSON.parse(req.body!).inputs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote / createTask', () => {
|
||||
it('createNote POSTs to /objects/notes with the payload as JSON', () => {
|
||||
const payload = { properties: { hs_note_body: 'hi' } };
|
||||
const req = createNote(ROOT, payload, TOKEN);
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.url).toBe('https://api.hubapi.com/crm/v3/objects/notes');
|
||||
expect(JSON.parse(req.body!)).toEqual(payload);
|
||||
expect(req.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
});
|
||||
it('createTask POSTs to /objects/tasks', () => {
|
||||
const req = createTask(ROOT, { properties: {} }, TOKEN);
|
||||
expect(req.url).toBe('https://api.hubapi.com/crm/v3/objects/tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseObject', () => {
|
||||
it('coerces property values to strings and defaults missing to ""', () => {
|
||||
const o = parseObject({ id: 7, properties: { amount: 1000, dealname: 'Big', note: null } });
|
||||
expect(o.id).toBe('7');
|
||||
expect(o.properties).toEqual({ amount: '1000', dealname: 'Big', note: '' });
|
||||
});
|
||||
it('tolerates a null/empty body', () => {
|
||||
expect(parseObject(null)).toEqual({ id: '', properties: {} });
|
||||
expect(parseObject({})).toEqual({ id: '', properties: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseObjects', () => {
|
||||
it('maps a results array and drops idless rows', () => {
|
||||
const list = parseObjects({
|
||||
results: [
|
||||
{ id: '1', properties: { a: 'x' } },
|
||||
{ properties: { a: 'y' } }, // no id → dropped
|
||||
{ id: '2', properties: {} },
|
||||
],
|
||||
});
|
||||
expect(list.map((o) => o.id)).toEqual(['1', '2']);
|
||||
});
|
||||
it('returns [] for a non-array body', () => {
|
||||
expect(parseObjects({})).toEqual([]);
|
||||
expect(parseObjects(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAssociationIds', () => {
|
||||
it('reads v4 toObjectId shape', () => {
|
||||
const ids = parseAssociationIds({ results: [{ toObjectId: 11 }, { toObjectId: '12' }] });
|
||||
expect(ids).toEqual(['11', '12']);
|
||||
});
|
||||
it('reads v3 id shape', () => {
|
||||
const ids = parseAssociationIds({ results: [{ id: 21 }, { id: 22 }] });
|
||||
expect(ids).toEqual(['21', '22']);
|
||||
});
|
||||
it('prefers toObjectId when both present, and skips empties', () => {
|
||||
const ids = parseAssociationIds({
|
||||
results: [{ toObjectId: 31, id: 999 }, { id: '' }, {}],
|
||||
});
|
||||
expect(ids).toEqual(['31']);
|
||||
});
|
||||
it('returns [] for a missing results array', () => {
|
||||
expect(parseAssociationIds({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
authorizeUrl,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
parseTokenResponse,
|
||||
} from '../src/oauth.js';
|
||||
|
||||
describe('authorizeUrl', () => {
|
||||
it('builds the authorize URL with space-joined scopes and state', () => {
|
||||
const url = authorizeUrl({
|
||||
clientId: 'cid',
|
||||
redirectUri: 'https://hubspot.hanzo.ai/oauth/callback',
|
||||
scopes: ['crm.objects.contacts.read', 'crm.objects.contacts.write'],
|
||||
state: 'csrf123',
|
||||
});
|
||||
const u = new URL(url);
|
||||
expect(u.origin + u.pathname).toBe('https://app.hubspot.com/oauth/authorize');
|
||||
expect(u.searchParams.get('client_id')).toBe('cid');
|
||||
expect(u.searchParams.get('redirect_uri')).toBe('https://hubspot.hanzo.ai/oauth/callback');
|
||||
expect(u.searchParams.get('scope')).toBe(
|
||||
'crm.objects.contacts.read crm.objects.contacts.write',
|
||||
);
|
||||
expect(u.searchParams.get('state')).toBe('csrf123');
|
||||
expect(u.searchParams.get('optional_scope')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds optional_scope only when optional scopes are given', () => {
|
||||
const url = authorizeUrl({
|
||||
clientId: 'cid',
|
||||
redirectUri: 'https://x/cb',
|
||||
scopes: ['a'],
|
||||
state: 's',
|
||||
optionalScopes: ['b', 'c'],
|
||||
});
|
||||
expect(new URL(url).searchParams.get('optional_scope')).toBe('b c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenExchange', () => {
|
||||
it('shapes the code→token request as a form body on /oauth/v1/token', () => {
|
||||
const req = tokenExchange({
|
||||
clientId: 'cid',
|
||||
clientSecret: 'secret',
|
||||
redirectUri: 'https://x/cb',
|
||||
code: 'abc',
|
||||
});
|
||||
expect(req.url).toBe('https://api.hubapi.com/oauth/v1/token');
|
||||
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('client_id')).toBe('cid');
|
||||
expect(body.get('client_secret')).toBe('secret');
|
||||
expect(body.get('redirect_uri')).toBe('https://x/cb');
|
||||
expect(body.get('code')).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps the secret out of the URL', () => {
|
||||
const req = tokenExchange({ clientId: 'c', clientSecret: 'topsecret', redirectUri: 'r', code: 'x' });
|
||||
expect(req.url).not.toContain('topsecret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshExchange', () => {
|
||||
it('shapes a refresh_token grant without a redirect_uri', () => {
|
||||
const req = refreshExchange({ clientId: 'cid', clientSecret: 'secret', refreshToken: 'rt' });
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).toBe('rt');
|
||||
expect(body.get('redirect_uri')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokenResponse', () => {
|
||||
it('parses a valid token set', () => {
|
||||
const t = parseTokenResponse({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 1800,
|
||||
token_type: 'bearer',
|
||||
});
|
||||
expect(t).toEqual({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 1800,
|
||||
token_type: 'bearer',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a HubSpot { status: "error" } payload with its message', () => {
|
||||
expect(() =>
|
||||
parseTokenResponse({ status: 'error', message: 'invalid code' }),
|
||||
).toThrow(/invalid code/);
|
||||
});
|
||||
|
||||
it('throws on an OAuth2 error payload', () => {
|
||||
expect(() =>
|
||||
parseTokenResponse({ error: 'invalid_grant', error_description: 'expired' }),
|
||||
).toThrow(/expired/);
|
||||
});
|
||||
|
||||
it('throws when access_token is missing', () => {
|
||||
expect(() => parseTokenResponse({ token_type: 'bearer' })).toThrow(/missing access_token/);
|
||||
});
|
||||
|
||||
it('throws on an empty response', () => {
|
||||
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
|
||||
});
|
||||
|
||||
it('drops non-string optional fields rather than trusting them', () => {
|
||||
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'x' });
|
||||
expect(t.refresh_token).toBeUndefined();
|
||||
expect(t.expires_in).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_PROPERTIES,
|
||||
ASSOCIATED_TYPES,
|
||||
typeLabel,
|
||||
renderObjectList,
|
||||
buildContextBlocks,
|
||||
} from '../src/record.js';
|
||||
import type { CrmObject } from '../src/api/hubspot-api.js';
|
||||
|
||||
const contact: CrmObject = {
|
||||
id: '1',
|
||||
properties: { firstname: 'Ada', lastname: 'Lovelace', email: 'ada@x.com' },
|
||||
};
|
||||
const deal: CrmObject = { id: '9', properties: { dealname: 'Big deal', amount: '5000' } };
|
||||
const company: CrmObject = { id: '7', properties: { name: 'Acme', domain: 'acme.com' } };
|
||||
|
||||
describe('property + association config', () => {
|
||||
it('requests curated properties per type', () => {
|
||||
expect(DEFAULT_PROPERTIES.contacts).toContain('email');
|
||||
expect(DEFAULT_PROPERTIES.deals).toContain('amount');
|
||||
expect(DEFAULT_PROPERTIES.companies).toContain('domain');
|
||||
});
|
||||
it('associates each type with its business context, in order', () => {
|
||||
expect(ASSOCIATED_TYPES.contacts).toEqual(['companies', 'deals']);
|
||||
expect(ASSOCIATED_TYPES.deals).toEqual(['contacts', 'companies']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typeLabel', () => {
|
||||
it('gives singular and plural labels', () => {
|
||||
expect(typeLabel('deals', false)).toBe('Deal');
|
||||
expect(typeLabel('companies', true)).toBe('Companies');
|
||||
expect(typeLabel('contacts', false)).toBe('Contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderObjectList', () => {
|
||||
it('renders each record and separates with a rule', () => {
|
||||
const s = renderObjectList([deal, { id: '10', properties: { dealname: 'Small' } }]);
|
||||
expect(s).toContain('dealname: Big deal');
|
||||
expect(s).toContain('dealname: Small');
|
||||
expect(s).toContain('\n---\n');
|
||||
});
|
||||
it('caps the number of records rendered', () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: String(i),
|
||||
properties: { dealname: `D${i}` },
|
||||
}));
|
||||
const s = renderObjectList(many, 3);
|
||||
expect(s).toContain('D0');
|
||||
expect(s).toContain('D2');
|
||||
expect(s).not.toContain('D3');
|
||||
});
|
||||
it('drops records with no renderable properties', () => {
|
||||
expect(renderObjectList([{ id: '1', properties: {} }])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildContextBlocks', () => {
|
||||
it('puts the primary record first, then non-empty association groups in order', () => {
|
||||
const blocks = buildContextBlocks('contacts', contact, {
|
||||
companies: [company],
|
||||
deals: [deal],
|
||||
});
|
||||
expect(blocks.map((b) => b.label)).toEqual([
|
||||
'Contact',
|
||||
'Associated Companies',
|
||||
'Associated Deals',
|
||||
]);
|
||||
expect(blocks[0].text).toContain('firstname: Ada');
|
||||
expect(blocks[1].text).toContain('name: Acme');
|
||||
expect(blocks[2].text).toContain('dealname: Big deal');
|
||||
});
|
||||
|
||||
it('omits empty association groups', () => {
|
||||
const blocks = buildContextBlocks('contacts', contact, { companies: [], deals: [deal] });
|
||||
expect(blocks.map((b) => b.label)).toEqual(['Contact', 'Associated Deals']);
|
||||
});
|
||||
|
||||
it('works with no associations at all', () => {
|
||||
const blocks = buildContextBlocks('deals', deal, {});
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].label).toBe('Deal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
noteWriteBack,
|
||||
taskWriteBack,
|
||||
NOTE_ASSOCIATION_TYPE_ID,
|
||||
TASK_ASSOCIATION_TYPE_ID,
|
||||
ASSOCIATION_CATEGORY,
|
||||
} from '../src/api/write-back.js';
|
||||
|
||||
const TS = '2026-07-03T12:00:00.000Z';
|
||||
|
||||
describe('association type id tables', () => {
|
||||
it('matches HubSpot HUBSPOT_DEFINED note defaults', () => {
|
||||
expect(NOTE_ASSOCIATION_TYPE_ID).toEqual({ contacts: 202, companies: 190, deals: 214 });
|
||||
});
|
||||
it('matches HubSpot HUBSPOT_DEFINED task defaults', () => {
|
||||
expect(TASK_ASSOCIATION_TYPE_ID).toEqual({ contacts: 204, companies: 192, deals: 216 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteWriteBack', () => {
|
||||
it('builds a note payload with body, timestamp, and the default contact edge', () => {
|
||||
const p = noteWriteBack('contacts', '123', 'Great call, ready to move forward.', TS);
|
||||
expect(p).toEqual({
|
||||
properties: {
|
||||
hs_note_body: 'Great call, ready to move forward.',
|
||||
hs_timestamp: TS,
|
||||
},
|
||||
associations: [
|
||||
{
|
||||
to: { id: '123' },
|
||||
types: [{ associationCategory: ASSOCIATION_CATEGORY, associationTypeId: 202 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the deal edge (214) for a deal record', () => {
|
||||
const p = noteWriteBack('deals', '900', 'x', TS);
|
||||
expect(p.associations[0].types[0].associationTypeId).toBe(214);
|
||||
expect(p.associations[0].to.id).toBe('900');
|
||||
});
|
||||
|
||||
it('uses the company edge (190) for a company record', () => {
|
||||
const p = noteWriteBack('companies', '5', 'x', TS);
|
||||
expect(p.associations[0].types[0].associationTypeId).toBe(190);
|
||||
});
|
||||
|
||||
it('defaults the timestamp to a real ISO string when omitted', () => {
|
||||
const p = noteWriteBack('contacts', '1', 'x');
|
||||
expect(p.properties.hs_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('passes the body through unchanged (no markup injection)', () => {
|
||||
const body = 'Line 1\n\nLine 2 with <b>bold</b> & symbols';
|
||||
const p = noteWriteBack('contacts', '1', body, TS);
|
||||
expect(p.properties.hs_note_body).toBe(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskWriteBack', () => {
|
||||
it('builds a task payload with subject, body, status/priority, due, and the default edge', () => {
|
||||
const p = taskWriteBack('deals', '42', 'Follow up on pricing', 'Send the revised quote', TS);
|
||||
expect(p).toEqual({
|
||||
properties: {
|
||||
hs_task_subject: 'Follow up on pricing',
|
||||
hs_task_body: 'Send the revised quote',
|
||||
hs_task_status: 'NOT_STARTED',
|
||||
hs_task_priority: 'MEDIUM',
|
||||
hs_timestamp: TS,
|
||||
},
|
||||
associations: [
|
||||
{
|
||||
to: { id: '42' },
|
||||
types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 216 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the contact task edge (204)', () => {
|
||||
const p = taskWriteBack('contacts', '1', 's', 'b', TS);
|
||||
expect(p.associations[0].types[0].associationTypeId).toBe(204);
|
||||
});
|
||||
|
||||
it('defaults the due timestamp when omitted', () => {
|
||||
const p = taskWriteBack('contacts', '1', 's', 'b');
|
||||
expect(p.properties.hs_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/config.ts",
|
||||
"src/hanzo.ts",
|
||||
"src/oauth.ts",
|
||||
"src/record.ts",
|
||||
"src/api/hubspot-api.ts",
|
||||
"src/api/write-back.ts"
|
||||
],
|
||||
"exclude": ["src/app/**", "test/**"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist/cjs",
|
||||
"declaration": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ packages:
|
||||
- 'packages/docusign'
|
||||
- 'packages/dxt'
|
||||
- 'packages/gworkspace'
|
||||
- 'packages/hubspot'
|
||||
- 'packages/jetbrains'
|
||||
- 'packages/mcp'
|
||||
- 'packages/meetings'
|
||||
|
||||
Reference in New Issue
Block a user