Files
zeekay 94476b43e2 feat(hubspot): Hanzo AI for HubSpot — CRM card + AI over records (@hanzo/hubspot)
A HubSpot public app on the developer-projects platform: a CRM card UI
extension (crm.record.tab, on contacts/companies/deals) backed by two
serverless functions. Summarize a record, draft an email, suggest next
steps, or ask — grounded in the record + its associated records — and
write the result back as a Note or Task.

Built on the published @hanzo/ai@^0.2.0 headless client (createAiClient,
/v1 chat.completions + models.list) + @hanzo/iam. The Hanzo API key
(HANZO_API_KEY secret) and the per-portal OAuth token live only in the
serverless backend; the browser never sees them.

Pure, unit-tested core (config, hanzo call + context windowing + the four
actions, HubSpot CRM v3 request shaping/parsing, OAuth token exchange,
record→context assembly, Note/Task write-back payloads). 75 vitest tests
green, tsc --noEmit clean, dual ESM+CJS build so the CJS serverless
runtime can require it.
2026-07-03 20:53:24 -07:00

87 lines
3.0 KiB
TypeScript

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: '[email protected]' },
};
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');
});
});