diff --git a/packages/plugins/official/hubspot-crm/__tests__/index.test.ts b/packages/plugins/official/hubspot-crm/__tests__/index.test.ts new file mode 100644 index 0000000..fab0223 --- /dev/null +++ b/packages/plugins/official/hubspot-crm/__tests__/index.test.ts @@ -0,0 +1,1386 @@ +/// +/** + * HubSpot CRM — Unit Tests + * + * Covers: DB key helpers, hubspotRequest (success + HTTP error), + * searchContacts, getContact, getDeals (with/without pipeline filter), + * createNote, associateNoteWithContact, updateDealStage, + * generateEnrichmentSummary (choices shape, content shape, error), + * plugin manifest/settings, app:init (6 endpoints), + * POST /connect (success, missing key, invalid key), + * GET /contacts (success, no API key, with query, with limit), + * GET /contacts/:id (success, no API key, 404, other error), + * POST /contacts/:id/enrich (success, no API key, missing text, with conversationId, AI error, HubSpot error), + * GET /deals (success, no API key, pipelineId from query, pipelineId from config, limit), + * POST /deals/:id/update-stage (success, no API key, missing stage, HubSpot error), + * conversation:end (autoLog false, no conversationId, stores pending, logs to HubSpot when association found, no API key stores pending only). + */ +import plugin, { + buildContactKey, + buildEnrichmentKey, + buildPendingKey, + buildAssociationKey, + hubspotRequest, + searchContacts, + getContact, + getDeals, + createNote, + associateNoteWithContact, + updateDealStage, + generateEnrichmentSummary, + HUBSPOT_API_BASE, + AI_COMPLETIONS_PATH, + CONNECTION_KEY, + DEFAULT_ENRICH_MODEL, + HubSpotContact, + HubSpotDeal, + HubSpotNote, + ContactInteractionRecord, + EnrichmentRecord, + PendingInteractionRecord, +} from "../src/index"; +import { + PluginContext, + PluginAPI, + PluginDatabaseAPI, + PluginEventBus, + EndpointDefinition, + EndpointRequest, + EndpointResponse, +} from "@agentbase/plugin-sdk"; + +// ── Mock factory ────────────────────────────────────────────────────────────── + +function createMockAPI( + configOverrides: Record = {}, +): PluginAPI & { _endpoints: EndpointDefinition[] } { + const store = new Map(); + const _endpoints: EndpointDefinition[] = []; + + const db: PluginDatabaseAPI = { + set: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => store.set(k, v)), + get: jest + .fn() + .mockImplementation(async (k: string) => store.get(k) ?? null), + delete: jest.fn().mockImplementation(async (k: string) => { + const had = store.has(k); + store.delete(k); + return had; + }), + keys: jest + .fn() + .mockImplementation(async (prefix?: string) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + + const events: PluginEventBus = { + emit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + }; + + const defaultConfig = new Map([ + ["hubspotApiKey", "pat-test-key"], + ["autoLogConversations", true], + ["enrichModel", "gpt-4o-mini"], + ["pipelineId", ""], + ...Object.entries(configOverrides), + ]); + + return { + _endpoints, + getConfig: jest + .fn() + .mockImplementation((k: string) => defaultConfig.get(k) ?? undefined), + setConfig: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => + defaultConfig.set(k, v), + ), + makeRequest: jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }), + log: jest.fn(), + db, + events, + registerEndpoint: jest + .fn() + .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)), + registerCronJob: jest.fn(), + registerWebhook: jest.fn(), + registerAdminPage: jest.fn(), + } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] }; +} + +type MockAPI = ReturnType; +type MockCtx = PluginContext & { api: MockAPI }; + +function makeCtx( + overrides: Partial = {}, + configOverrides: Record = {}, +): MockCtx { + const api = createMockAPI(configOverrides); + return { + appId: "app-1", + userId: "user-1", + config: {}, + api, + ...overrides, + } as MockCtx; +} + +interface MockRes { + status: jest.Mock; + json: jest.Mock; + _status: number; + _body: unknown; +} + +function makeRes(): MockRes { + const r: MockRes = { + _status: 200, + _body: undefined, + status: jest.fn(), + json: jest.fn(), + }; + r.status.mockImplementation((code: number) => { + r._status = code; + return r; + }); + r.json.mockImplementation((body: unknown) => { + r._body = body; + }); + return r; +} + +function makeReq(overrides: Partial = {}): EndpointRequest { + return { + method: "GET", + path: "/", + params: {}, + query: {}, + body: {}, + headers: {}, + ...overrides, + }; +} + +async function initPlugin(api: MockAPI): Promise { + const hook = plugin.definition.hooks?.["app:init"]; + await hook?.({ appId: "app-1", userId: "user-1", config: {}, api }); + return api._endpoints; +} + +function getEp( + endpoints: EndpointDefinition[], + method: string, + path: string, +): EndpointDefinition | undefined { + return endpoints.find((e) => e.method === method && e.path === path); +} + +// Stub a successful makeRequest returning JSON +function okJson(data: T): jest.Mock { + return jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(data), + text: jest.fn().mockResolvedValue(""), + }); +} + +// Stub a failed makeRequest +function failHttp(status: number, body = "Bad Request"): jest.Mock { + return jest.fn().mockResolvedValue({ + ok: false, + status, + statusText: body, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(body), + }); +} + +// ── DB Key helpers ──────────────────────────────────────────────────────────── + +describe("buildContactKey", () => { + it("formats contact:hubspotId:conversationId", () => { + expect(buildContactKey("hs-123", "conv-456")).toBe( + "contact:hs-123:conv-456", + ); + }); + + it("handles ids with special characters", () => { + expect(buildContactKey("a:b", "c:d")).toBe("contact:a:b:c:d"); + }); +}); + +describe("buildEnrichmentKey", () => { + it("formats enrichment:contactId", () => { + expect(buildEnrichmentKey("contact-99")).toBe("enrichment:contact-99"); + }); +}); + +describe("buildPendingKey", () => { + it("formats pending:conversationId", () => { + expect(buildPendingKey("conv-abc")).toBe("pending:conv-abc"); + }); +}); + +describe("buildAssociationKey", () => { + it("formats association:conversationId", () => { + expect(buildAssociationKey("conv-xyz")).toBe("association:conv-xyz"); + }); +}); + +// ── Constants ───────────────────────────────────────────────────────────────── + +describe("constants", () => { + it("HUBSPOT_API_BASE is the HubSpot API root", () => { + expect(HUBSPOT_API_BASE).toBe("https://api.hubapi.com"); + }); + + it("CONNECTION_KEY is connection:config", () => { + expect(CONNECTION_KEY).toBe("connection:config"); + }); + + it("DEFAULT_ENRICH_MODEL is gpt-4o-mini", () => { + expect(DEFAULT_ENRICH_MODEL).toBe("gpt-4o-mini"); + }); + + it("AI_COMPLETIONS_PATH has the correct prefix", () => { + expect(AI_COMPLETIONS_PATH).toMatch(/^\/api\/v1/); + }); +}); + +// ── hubspotRequest ──────────────────────────────────────────────────────────── + +describe("hubspotRequest", () => { + it("sends GET with Authorization header", async () => { + const mr = okJson({ results: [] }); + await hubspotRequest(mr, "my-key", "GET", "/crm/v3/objects/contacts"); + expect(mr).toHaveBeenCalledWith( + `${HUBSPOT_API_BASE}/crm/v3/objects/contacts`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer my-key" }), + }), + ); + }); + + it("sends POST body as JSON string", async () => { + const mr = okJson({ id: "1", properties: {} }); + await hubspotRequest(mr, "key", "POST", "/crm/v3/objects/notes", { + properties: { hs_note_body: "hello" }, + }); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + properties: { hs_note_body: "hello" }, + }); + }); + + it("sends PATCH without body when body is undefined", async () => { + const mr = okJson({ id: "d1", properties: {} }); + await hubspotRequest(mr, "key", "GET", "/crm/v3/objects/deals/d1"); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + expect(init.body).toBeUndefined(); + }); + + it("throws on non-2xx response", async () => { + const mr = failHttp(401, "Unauthorized"); + await expect( + hubspotRequest(mr, "bad-key", "GET", "/crm/v3/objects/contacts"), + ).rejects.toThrow("HubSpot API error 401"); + }); + + it("returns the parsed JSON on success", async () => { + const payload = { id: "42", properties: { firstname: "Ada" } }; + const mr = okJson(payload); + const result = await hubspotRequest( + mr, + "key", + "GET", + "/crm/v3/objects/contacts/42", + ); + expect(result.id).toBe("42"); + expect(result.properties.firstname).toBe("Ada"); + }); +}); + +// ── searchContacts ──────────────────────────────────────────────────────────── + +describe("searchContacts", () => { + it("calls POST /crm/v3/objects/contacts/search", async () => { + const contacts: HubSpotContact[] = [ + { id: "1", properties: { email: "ada@example.com" } }, + ]; + const mr = okJson({ results: contacts, total: 1 }); + const result = await searchContacts(mr, "key", "ada", 10); + expect(mr).toHaveBeenCalledWith( + `${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`, + expect.objectContaining({ method: "POST" }), + ); + expect(result).toHaveLength(1); + expect(result[0].properties.email).toBe("ada@example.com"); + }); + + it("returns empty array when results is empty", async () => { + const mr = okJson({ results: [], total: 0 }); + const result = await searchContacts(mr, "key", "nobody", 20); + expect(result).toEqual([]); + }); + + it("passes query and limit in POST body", async () => { + const mr = okJson({ results: [], total: 0 }); + await searchContacts(mr, "key", "test query", 15); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.query).toBe("test query"); + expect(body.limit).toBe(15); + }); +}); + +// ── getContact ──────────────────────────────────────────────────────────────── + +describe("getContact", () => { + it("calls GET /crm/v3/objects/contacts/:id with properties", async () => { + const contact: HubSpotContact = { + id: "c1", + properties: { + firstname: "Grace", + lastname: "Hopper", + email: "grace@navy.mil", + }, + }; + const mr = okJson(contact); + const result = await getContact(mr, "key", "c1"); + const [url] = (mr as jest.Mock).mock.calls[0] as [string]; + expect(url).toContain("/crm/v3/objects/contacts/c1"); + expect(url).toContain("properties="); + expect(result.properties.firstname).toBe("Grace"); + }); + + it("propagates 404 errors from HubSpot", async () => { + const mr = failHttp(404, "Not Found"); + await expect(getContact(mr, "key", "missing")).rejects.toThrow("404"); + }); + + it("returns full contact properties", async () => { + const contact: HubSpotContact = { + id: "c2", + properties: { company: "ENIAC Corp", phone: "555-0100" }, + }; + const mr = okJson(contact); + const result = await getContact(mr, "key", "c2"); + expect(result.properties.company).toBe("ENIAC Corp"); + }); +}); + +// ── getDeals ────────────────────────────────────────────────────────────────── + +describe("getDeals", () => { + const allDeals: HubSpotDeal[] = [ + { + id: "d1", + properties: { + dealname: "Deal A", + dealstage: "appointmentscheduled", + pipeline: "pipe-1", + }, + }, + { + id: "d2", + properties: { + dealname: "Deal B", + dealstage: "qualifiedtobuy", + pipeline: "pipe-2", + }, + }, + ]; + + it("returns all deals when no pipelineId", async () => { + const mr = okJson({ results: allDeals }); + const result = await getDeals(mr, "key"); + expect(result).toHaveLength(2); + }); + + it("filters deals by pipelineId", async () => { + const mr = okJson({ results: allDeals }); + const result = await getDeals(mr, "key", "pipe-1"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("d1"); + }); + + it("includes limit and properties in query string", async () => { + const mr = okJson({ results: [] }); + await getDeals(mr, "key", undefined, 50); + const [url] = (mr as jest.Mock).mock.calls[0] as [string]; + expect(url).toContain("limit=50"); + expect(url).toContain("properties="); + }); + + it("returns empty array when no deals exist", async () => { + const mr = okJson({ results: [] }); + const result = await getDeals(mr, "key"); + expect(result).toEqual([]); + }); +}); + +// ── createNote ──────────────────────────────────────────────────────────────── + +describe("createNote", () => { + it("calls POST /crm/v3/objects/notes with note body", async () => { + const note: HubSpotNote = { + id: "n1", + properties: { + hs_note_body: "Test note", + hs_timestamp: "2026-01-01T00:00:00.000Z", + }, + }; + const mr = okJson(note); + const result = await createNote(mr, "key", "Test note"); + expect(mr).toHaveBeenCalledWith( + `${HUBSPOT_API_BASE}/crm/v3/objects/notes`, + expect.objectContaining({ method: "POST" }), + ); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.properties.hs_note_body).toBe("Test note"); + expect(result.id).toBe("n1"); + }); + + it("includes hs_timestamp in the request", async () => { + const mr = okJson({ id: "n2", properties: {} }); + await createNote(mr, "key", "Hello"); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(typeof body.properties.hs_timestamp).toBe("string"); + expect(body.properties.hs_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("throws on HubSpot error", async () => { + const mr = failHttp(403, "Forbidden"); + await expect(createNote(mr, "key", "body")).rejects.toThrow("403"); + }); +}); + +// ── associateNoteWithContact ────────────────────────────────────────────────── + +describe("associateNoteWithContact", () => { + it("posts to associations batch endpoint with correct payload", async () => { + const mr = okJson({ results: [] }); + await associateNoteWithContact(mr, "key", "note-1", "contact-1"); + const [url, init] = (mr as jest.Mock).mock.calls[0] as [ + string, + RequestInit, + ]; + expect(url).toContain("/crm/v3/associations/notes/contacts/batch/create"); + const body = JSON.parse(init.body as string); + expect(body.inputs[0].from.id).toBe("note-1"); + expect(body.inputs[0].to.id).toBe("contact-1"); + expect(body.inputs[0].type).toBe("note_to_contact"); + }); + + it("throws when association POST fails", async () => { + const mr = failHttp(400, "Bad input"); + await expect( + associateNoteWithContact(mr, "key", "n1", "c1"), + ).rejects.toThrow("400"); + }); +}); + +// ── updateDealStage ─────────────────────────────────────────────────────────── + +describe("updateDealStage", () => { + it("sends PATCH to /crm/v3/objects/deals/:id with stage", async () => { + const deal: HubSpotDeal = { + id: "d1", + properties: { dealstage: "closedwon", pipeline: "pipe-1" }, + }; + const mr = okJson(deal); + const result = await updateDealStage(mr, "key", "d1", "closedwon"); + const [url, init] = (mr as jest.Mock).mock.calls[0] as [ + string, + RequestInit, + ]; + expect(url).toContain("/crm/v3/objects/deals/d1"); + expect((init as RequestInit).method).toBe("PATCH"); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.properties.dealstage).toBe("closedwon"); + expect(result.properties.dealstage).toBe("closedwon"); + }); + + it("throws on HubSpot error", async () => { + const mr = failHttp(404, "Deal not found"); + await expect( + updateDealStage(mr, "key", "missing", "stage"), + ).rejects.toThrow("404"); + }); +}); + +// ── generateEnrichmentSummary ───────────────────────────────────────────────── + +describe("generateEnrichmentSummary", () => { + it("extracts summary from choices[0].message.content", async () => { + const mr = okJson({ + choices: [{ message: { content: "CRM summary text." } }], + }); + const result = await generateEnrichmentSummary( + mr, + "User said hi.", + "gpt-4o-mini", + ); + expect(result).toBe("CRM summary text."); + }); + + it("falls back to top-level content field", async () => { + const mr = okJson({ content: "Fallback summary." }); + const result = await generateEnrichmentSummary(mr, "Hello world"); + expect(result).toBe("Fallback summary."); + }); + + it("throws on unexpected AI response shape", async () => { + const mr = okJson({ unexpected: true }); + await expect(generateEnrichmentSummary(mr, "Hello")).rejects.toThrow( + "Unexpected AI response shape", + ); + }); + + it("throws on non-200 AI response", async () => { + const mr = failHttp(500, "Internal Server Error"); + await expect(generateEnrichmentSummary(mr, "Hello")).rejects.toThrow( + "AI service error: 500", + ); + }); + + it("uses DEFAULT_ENRICH_MODEL when model is omitted", async () => { + const mr = okJson({ + choices: [{ message: { content: "summary" } }], + }); + await generateEnrichmentSummary(mr, "text"); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.model).toBe(DEFAULT_ENRICH_MODEL); + }); + + it("sends conversation text in the prompt", async () => { + const mr = okJson({ + choices: [{ message: { content: "ok" } }], + }); + await generateEnrichmentSummary(mr, "The user asked about pricing."); + const [, init] = (mr as jest.Mock).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + const userMsg = body.messages.find( + (m: { role: string }) => m.role === "user", + ); + expect(userMsg?.content).toContain("The user asked about pricing."); + }); +}); + +// ── Plugin manifest ─────────────────────────────────────────────────────────── + +describe("plugin manifest", () => { + it("has name hubspot-crm", () => { + expect(plugin.manifest.name).toBe("hubspot-crm"); + }); + + it("has version 1.0.0", () => { + expect(plugin.manifest.version).toBe("1.0.0"); + }); + + it("requires network:external and db:readwrite permissions", () => { + expect(plugin.manifest.permissions).toContain("network:external"); + expect(plugin.manifest.permissions).toContain("db:readwrite"); + }); +}); + +// ── Plugin settings ─────────────────────────────────────────────────────────── + +describe("plugin settings", () => { + const settings = plugin.definition.settings!; + + it("has hubspotApiKey as encrypted string", () => { + expect(settings["hubspotApiKey"].type).toBe("string"); + expect(settings["hubspotApiKey"].encrypted).toBe(true); + }); + + it("has autoLogConversations boolean defaulting to true", () => { + expect(settings["autoLogConversations"].type).toBe("boolean"); + expect(settings["autoLogConversations"].default).toBe(true); + }); + + it("has enrichModel select with gpt-4o-mini as default", () => { + expect(settings["enrichModel"].type).toBe("select"); + expect(settings["enrichModel"].default).toBe("gpt-4o-mini"); + expect(settings["enrichModel"].options).toContain("gpt-4o"); + expect(settings["enrichModel"].options).toContain("claude-3-5-sonnet"); + }); + + it("has pipelineId string setting", () => { + expect(settings["pipelineId"].type).toBe("string"); + }); + + it("defines exactly 4 settings", () => { + expect(Object.keys(settings)).toHaveLength(4); + }); +}); + +// ── app:init — endpoint registration ───────────────────────────────────────── + +describe("app:init hook", () => { + it("registers exactly 6 endpoints", async () => { + const api = createMockAPI(); + await initPlugin(api); + expect(api._endpoints).toHaveLength(6); + }); + + it("all endpoints require auth", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(endpoints.every((e) => e.auth === true)).toBe(true); + }); + + it("registers POST /connect", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "POST", "/connect")).toBeDefined(); + }); + + it("registers GET /contacts", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "GET", "/contacts")).toBeDefined(); + }); + + it("registers GET /contacts/:id", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "GET", "/contacts/:id")).toBeDefined(); + }); + + it("registers POST /contacts/:id/enrich", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "POST", "/contacts/:id/enrich")).toBeDefined(); + }); + + it("registers GET /deals", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "GET", "/deals")).toBeDefined(); + }); + + it("registers POST /deals/:id/update-stage", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + expect(getEp(endpoints, "POST", "/deals/:id/update-stage")).toBeDefined(); + }); +}); + +// ── POST /connect ───────────────────────────────────────────────────────────── + +describe("POST /connect", () => { + it("validates key, stores config, returns connected: true", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ results: [] }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/connect")!; + const req = makeReq({ body: { apiKey: "pat-live-123" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(200); + expect(res._body).toMatchObject({ connected: true }); + expect(api.db.set).toHaveBeenCalledWith( + CONNECTION_KEY, + expect.objectContaining({ apiKey: "pat-live-123" }), + ); + }); + + it("returns 400 when apiKey is missing", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/connect")!; + const req = makeReq({ body: {} }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + error: expect.stringContaining("required"), + }); + }); + + it("returns 400 when HubSpot rejects the token", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Unauthorized"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/connect")!; + const req = makeReq({ body: { apiKey: "bad-key" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + error: expect.stringContaining("Invalid API key"), + }); + }); +}); + +// ── GET /contacts ───────────────────────────────────────────────────────────── + +describe("GET /contacts", () => { + it("returns contacts from HubSpot search", async () => { + const contacts: HubSpotContact[] = [ + { id: "c1", properties: { email: "test@example.com" } }, + ]; + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ results: contacts, total: 1 }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts")!; + const req = makeReq({ query: { q: "test" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(200); + expect(res._body).toMatchObject({ + contacts: expect.arrayContaining([expect.objectContaining({ id: "c1" })]), + }); + }); + + it("returns 400 when API key not configured", async () => { + const api = createMockAPI({ hubspotApiKey: "" }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts")!; + const res = makeRes(); + + await ep.handler(makeReq(), res as unknown as EndpointResponse); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + error: expect.stringContaining("not configured"), + }); + }); + + it("caps limit at 100", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ results: [], total: 0 }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts")!; + const req = makeReq({ query: { q: "", limit: "500" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + // makeRequest is called for the search; check the body's limit field + const allCalls = (api.makeRequest as jest.Mock).mock.calls as [ + string, + RequestInit, + ][]; + const searchCall = allCalls.find(([url]) => + url.includes("/contacts/search"), + ); + const body = JSON.parse(searchCall![1].body as string); + expect(body.limit).toBeLessThanOrEqual(100); + }); + + it("returns 502 on HubSpot network error", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Service Unavailable"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts")!; + const req = makeReq({ query: { q: "x" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(502); + }); +}); + +// ── GET /contacts/:id ───────────────────────────────────────────────────────── + +describe("GET /contacts/:id", () => { + it("returns a single contact", async () => { + const contact: HubSpotContact = { + id: "c1", + properties: { firstname: "Alan", lastname: "Turing" }, + }; + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(contact), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts/:id")!; + const req = makeReq({ params: { id: "c1" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(200); + expect(res._body).toMatchObject({ contact: { id: "c1" } }); + }); + + it("returns 400 when API key not configured", async () => { + const api = createMockAPI({ hubspotApiKey: "" }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts/:id")!; + const res = makeRes(); + + await ep.handler( + makeReq({ params: { id: "x" } }), + res as unknown as EndpointResponse, + ); + + expect(res._status).toBe(400); + }); + + it("returns 404 when HubSpot returns 404", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Not Found"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts/:id")!; + const req = makeReq({ params: { id: "missing" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(404); + expect(res._body).toMatchObject({ error: "Contact not found" }); + }); + + it("returns 502 on other HubSpot errors", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("error"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/contacts/:id")!; + const req = makeReq({ params: { id: "c1" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(502); + }); +}); + +// ── POST /contacts/:id/enrich ───────────────────────────────────────────────── + +describe("POST /contacts/:id/enrich", () => { + async function callEnrich( + api: MockAPI, + params: Record, + body: Record, + ) { + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/contacts/:id/enrich")!; + const req = makeReq({ params, body }); + const res = makeRes(); + await ep.handler(req, res as unknown as EndpointResponse); + return res; + } + + it("generates summary, creates note, stores enrichment record", async () => { + const api = createMockAPI(); + const makeReqMock = api.makeRequest as jest.Mock; + + // Call 1: AI summary + makeReqMock.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [ + { message: { content: "Contact is interested in Pro plan." } }, + ], + }), + text: jest.fn().mockResolvedValue(""), + }); + // Call 2: create note + makeReqMock.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + id: "n1", + properties: { + hs_note_body: "Contact is interested in Pro plan.", + hs_timestamp: "", + }, + }), + text: jest.fn().mockResolvedValue(""), + }); + // Call 3: associate note + makeReqMock.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: [] }), + text: jest.fn().mockResolvedValue(""), + }); + + const res = await callEnrich( + api, + { id: "c1" }, + { + conversationText: "User asked about pricing.", + conversationId: "conv-1", + }, + ); + + expect(res._status).toBe(200); + expect(res._body).toMatchObject({ + summary: "Contact is interested in Pro plan.", + noteId: "n1", + }); + expect(api.db.set).toHaveBeenCalledWith( + buildEnrichmentKey("c1"), + expect.objectContaining({ contactId: "c1", summary: expect.any(String) }), + ); + expect(api.db.set).toHaveBeenCalledWith( + buildContactKey("c1", "conv-1"), + expect.objectContaining({ + hubspotContactId: "c1", + conversationId: "conv-1", + }), + ); + }); + + it("stores enrichment even without conversationId", async () => { + const api = createMockAPI(); + const makeReqMock = api.makeRequest as jest.Mock; + makeReqMock + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "A summary." } }], + }), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ id: "n2", properties: {} }), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: [] }), + text: jest.fn().mockResolvedValue(""), + }); + + const res = await callEnrich( + api, + { id: "c2" }, + { conversationText: "text" }, + ); + + expect(res._status).toBe(200); + // enrichment record stored, but no contact-interaction record + expect(api.db.set).toHaveBeenCalledWith( + buildEnrichmentKey("c2"), + expect.objectContaining({ contactId: "c2" }), + ); + const setCallKeys = (api.db.set as jest.Mock).mock.calls.map( + ([k]: [string]) => k, + ); + expect(setCallKeys.some((k: string) => k.startsWith("contact:"))).toBe( + false, + ); + }); + + it("returns 400 when API key not configured", async () => { + const api = createMockAPI({ hubspotApiKey: "" }); + const res = await callEnrich( + api, + { id: "c1" }, + { conversationText: "text" }, + ); + expect(res._status).toBe(400); + }); + + it("returns 400 when conversationText is missing", async () => { + const api = createMockAPI(); + const res = await callEnrich(api, { id: "c1" }, {}); + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + error: expect.stringContaining("required"), + }); + }); + + it("returns 502 on AI service error", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("error"), + }); + const res = await callEnrich( + api, + { id: "c1" }, + { conversationText: "text" }, + ); + expect(res._status).toBe(502); + }); + + it("returns 502 on HubSpot note creation error", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "summary" } }], + }), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Forbidden"), + }); + + const res = await callEnrich( + api, + { id: "c1" }, + { conversationText: "text" }, + ); + expect(res._status).toBe(502); + }); +}); + +// ── GET /deals ──────────────────────────────────────────────────────────────── + +describe("GET /deals", () => { + const deals: HubSpotDeal[] = [ + { + id: "d1", + properties: { + dealname: "Enterprise", + pipeline: "default", + dealstage: "closedwon", + }, + }, + { + id: "d2", + properties: { + dealname: "Starter", + pipeline: "pipe-2", + dealstage: "appointmentscheduled", + }, + }, + ]; + + it("returns all deals when no pipeline filter", async () => { + const api = createMockAPI({ pipelineId: "" }); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: deals }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/deals")!; + const res = makeRes(); + + await ep.handler(makeReq(), res as unknown as EndpointResponse); + + expect(res._status).toBe(200); + expect((res._body as { deals: unknown[] }).deals).toHaveLength(2); + }); + + it("filters by pipelineId in query param", async () => { + const api = createMockAPI({ pipelineId: "" }); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: deals }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/deals")!; + const req = makeReq({ query: { pipelineId: "pipe-2" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect((res._body as { deals: HubSpotDeal[] }).deals).toHaveLength(1); + expect((res._body as { deals: HubSpotDeal[] }).deals[0].id).toBe("d2"); + }); + + it("uses pipelineId from plugin config when not in query", async () => { + const api = createMockAPI({ pipelineId: "default" }); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: deals }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/deals")!; + const res = makeRes(); + + await ep.handler(makeReq(), res as unknown as EndpointResponse); + + expect((res._body as { deals: HubSpotDeal[] }).deals).toHaveLength(1); + expect((res._body as { deals: HubSpotDeal[] }).deals[0].id).toBe("d1"); + }); + + it("returns 400 when API key not configured", async () => { + const api = createMockAPI({ hubspotApiKey: "" }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/deals")!; + const res = makeRes(); + + await ep.handler(makeReq(), res as unknown as EndpointResponse); + + expect(res._status).toBe(400); + }); + + it("returns 502 on HubSpot error", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 502, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Bad Gateway"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "GET", "/deals")!; + const res = makeRes(); + + await ep.handler(makeReq(), res as unknown as EndpointResponse); + + expect(res._status).toBe(502); + }); +}); + +// ── POST /deals/:id/update-stage ────────────────────────────────────────────── + +describe("POST /deals/:id/update-stage", () => { + it("updates the deal stage and returns deal", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + id: "d1", + properties: { dealstage: "closedwon", pipeline: "default" }, + }), + text: jest.fn().mockResolvedValue(""), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/deals/:id/update-stage")!; + const req = makeReq({ params: { id: "d1" }, body: { stage: "closedwon" } }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(200); + expect((res._body as { deal: HubSpotDeal }).deal.properties.dealstage).toBe( + "closedwon", + ); + }); + + it("returns 400 when API key not configured", async () => { + const api = createMockAPI({ hubspotApiKey: "" }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/deals/:id/update-stage")!; + const res = makeRes(); + + await ep.handler( + makeReq({ params: { id: "d1" }, body: { stage: "closedlost" } }), + res as unknown as EndpointResponse, + ); + + expect(res._status).toBe(400); + }); + + it("returns 400 when stage is missing", async () => { + const api = createMockAPI(); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/deals/:id/update-stage")!; + const req = makeReq({ params: { id: "d1" }, body: {} }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + error: expect.stringContaining("required"), + }); + }); + + it("returns 502 on HubSpot error", async () => { + const api = createMockAPI(); + (api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Not Found"), + }); + const endpoints = await initPlugin(api); + const ep = getEp(endpoints, "POST", "/deals/:id/update-stage")!; + const req = makeReq({ + params: { id: "missing" }, + body: { stage: "closedwon" }, + }); + const res = makeRes(); + + await ep.handler(req, res as unknown as EndpointResponse); + + expect(res._status).toBe(502); + }); +}); + +// ── conversation:end hook ───────────────────────────────────────────────────── + +describe("conversation:end hook", () => { + async function fireHook(ctx: MockCtx): Promise { + const hook = plugin.definition.hooks?.["conversation:end"]; + await hook?.(ctx); + } + + it("skips everything when autoLogConversations is false", async () => { + const ctx = makeCtx({ userId: "u1" }, { autoLogConversations: false }); + (ctx as unknown as Record)["conversationId"] = "conv-1"; + + await fireHook(ctx); + + expect(ctx.api.db.set).not.toHaveBeenCalled(); + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("skips when conversationId is not on context", async () => { + const ctx = makeCtx({ userId: "u1" }); + // no conversationId set + + await fireHook(ctx); + + expect(ctx.api.db.set).not.toHaveBeenCalled(); + }); + + it("stores a pending record when autoLogConversations is true", async () => { + const ctx = makeCtx({ userId: "u1" }); + (ctx as unknown as Record)["conversationId"] = "conv-2"; + + await fireHook(ctx); + + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildPendingKey("conv-2"), + expect.objectContaining({ conversationId: "conv-2", userId: "u1" }), + ); + }); + + it("only stores pending when API key is not configured", async () => { + const ctx = makeCtx({ userId: "u1" }, { hubspotApiKey: "" }); + (ctx as unknown as Record)["conversationId"] = "conv-3"; + + await fireHook(ctx); + + expect(ctx.api.db.set).toHaveBeenCalledTimes(1); + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("creates a HubSpot note when API key and contact association exist", async () => { + const ctx = makeCtx({ userId: "u1" }, { hubspotApiKey: "pat-123" }); + (ctx as unknown as Record)["conversationId"] = "conv-4"; + + // Pre-seed contact association + const db = ctx.api.db as PluginDatabaseAPI & { + _store?: Map; + }; + await db.set(buildAssociationKey("conv-4"), { hubspotContactId: "hs-c1" }); + + // makeRequest: set call happened above; now mock the two HubSpot calls + const makeReqMock = ctx.api.makeRequest as jest.Mock; + makeReqMock + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ id: "note-99", properties: {} }), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ results: [] }), + text: jest.fn().mockResolvedValue(""), + }); + + await fireHook(ctx); + + // pending record + enrichment interaction record + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildPendingKey("conv-4"), + expect.objectContaining({ conversationId: "conv-4" }), + ); + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildContactKey("hs-c1", "conv-4"), + expect.objectContaining({ hubspotContactId: "hs-c1", noteId: "note-99" }), + ); + }); + + it("silently ignores HubSpot errors during note creation", async () => { + const ctx = makeCtx({ userId: "u1" }, { hubspotApiKey: "pat-123" }); + (ctx as unknown as Record)["conversationId"] = "conv-5"; + await ctx.api.db.set(buildAssociationKey("conv-5"), { + hubspotContactId: "hs-c2", + }); + + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Service Unavailable"), + }); + + // Should not throw + await expect(fireHook(ctx)).resolves.toBeUndefined(); + // pending record still stored + expect(ctx.api.db.set).toHaveBeenCalledWith( + buildPendingKey("conv-5"), + expect.anything(), + ); + }); +}); diff --git a/packages/plugins/official/hubspot-crm/__tests__/tsconfig.json b/packages/plugins/official/hubspot-crm/__tests__/tsconfig.json new file mode 100644 index 0000000..a05feed --- /dev/null +++ b/packages/plugins/official/hubspot-crm/__tests__/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": "..", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"] + } + }, + "include": ["../src/**/*", "./**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/official/hubspot-crm/manifest.json b/packages/plugins/official/hubspot-crm/manifest.json new file mode 100644 index 0000000..fa8a640 --- /dev/null +++ b/packages/plugins/official/hubspot-crm/manifest.json @@ -0,0 +1,53 @@ +{ + "name": "hubspot-crm", + "version": "1.0.0", + "description": "Enrich HubSpot CRM contacts with AI conversation summaries. Auto-log interactions, update deal stages, and generate lead scores from chat data.", + "author": "Agentbase", + "license": "GPL-3.0", + "main": "dist/index.js", + "agentbase": { + "type": "plugin", + "apiVersion": "1" + }, + "hooks": ["app:init", "conversation:end"], + "endpoints": [ + { "method": "POST", "path": "/connect" }, + { "method": "GET", "path": "/contacts" }, + { "method": "GET", "path": "/contacts/:id" }, + { "method": "POST", "path": "/contacts/:id/enrich" }, + { "method": "GET", "path": "/deals" }, + { "method": "POST", "path": "/deals/:id/update-stage" } + ], + "settings": [ + { + "key": "hubspotApiKey", + "type": "string", + "label": "HubSpot Private App Token", + "encrypted": true + }, + { + "key": "autoLogConversations", + "type": "boolean", + "label": "Automatically log conversations to HubSpot contact timelines", + "default": true + }, + { + "key": "enrichModel", + "type": "select", + "label": "AI Model for Contact Enrichment", + "options": [ + "gpt-4o-mini", + "gpt-4o", + "claude-3-5-haiku", + "claude-3-5-sonnet" + ], + "default": "gpt-4o-mini" + }, + { + "key": "pipelineId", + "type": "string", + "label": "HubSpot Pipeline ID (optional — used to filter deals)" + } + ], + "permissions": ["network:external", "db:readwrite"] +} diff --git a/packages/plugins/official/hubspot-crm/package.json b/packages/plugins/official/hubspot-crm/package.json new file mode 100644 index 0000000..4008583 --- /dev/null +++ b/packages/plugins/official/hubspot-crm/package.json @@ -0,0 +1,35 @@ +{ + "name": "@agentbase/plugin-hubspot-crm", + "version": "1.0.0", + "description": "Enrich HubSpot CRM contacts with AI conversation summaries. Auto-log interactions, update deal stages, and generate lead scores from chat data.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "@agentbase/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": [ + "/__tests__" + ], + "globals": { + "ts-jest": { + "tsconfig": "__tests__/tsconfig.json" + } + } + }, + "license": "GPL-3.0" +} diff --git a/packages/plugins/official/hubspot-crm/src/index.ts b/packages/plugins/official/hubspot-crm/src/index.ts new file mode 100644 index 0000000..985f9c7 --- /dev/null +++ b/packages/plugins/official/hubspot-crm/src/index.ts @@ -0,0 +1,680 @@ +/** + * HubSpot CRM + * + * Enrich HubSpot CRM contacts with AI conversation summaries. Auto-log + * interactions, update deal stages, and generate lead scores from chat data. + * + * All external HubSpot requests use `makeRequest` with Bearer-token auth + * (Private App Token). The AI enrichment summary is generated via the + * platform's internal AI service. + * + * Plugin DB keys: + * - `connection:config` — stored HubSpot API key + metadata + * - `contact:{hubspotId}:{conversationId}` — logged interaction record + * - `enrichment:{contactId}` — latest AI enrichment record + * - `pending:{conversationId}` — queued conversation awaiting link + * - `association:{conversationId}` — maps conversationId → hubspotContactId + * + * @package @agentbase/plugin-hubspot-crm + * @version 1.0.0 + */ +import { createPlugin, PluginContext } from "@agentbase/plugin-sdk"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const HUBSPOT_API_BASE = "https://api.hubapi.com"; +export const AI_COMPLETIONS_PATH = "/api/v1/internal/ai/completions"; +export const CONNECTION_KEY = "connection:config"; +export const DEFAULT_ENRICH_MODEL = "gpt-4o-mini"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface HubSpotContactProperties { + firstname?: string; + lastname?: string; + email?: string; + company?: string; + phone?: string; +} + +export interface HubSpotContact { + id: string; + properties: HubSpotContactProperties; +} + +export interface HubSpotDealProperties { + dealname?: string; + dealstage?: string; + pipeline?: string; + amount?: string; + closedate?: string; +} + +export interface HubSpotDeal { + id: string; + properties: HubSpotDealProperties; +} + +export interface HubSpotNote { + id: string; + properties: { + hs_note_body: string; + hs_timestamp: string; + }; +} + +export interface HubSpotListResult { + results: T[]; + paging?: { + next?: { after: string }; + }; +} + +export interface HubSpotSearchResult { + results: T[]; + total: number; +} + +export interface ContactInteractionRecord { + hubspotContactId: string; + conversationId: string; + userId: string; + noteId?: string; + loggedAt: number; +} + +export interface EnrichmentRecord { + contactId: string; + summary: string; + model: string; + generatedAt: number; +} + +export interface PendingInteractionRecord { + conversationId: string; + userId: string; + queuedAt: number; +} + +export interface ConnectionConfig { + apiKey: string; + connectedAt: number; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +export function buildContactKey( + hubspotId: string, + conversationId: string, +): string { + return `contact:${hubspotId}:${conversationId}`; +} + +export function buildEnrichmentKey(contactId: string): string { + return `enrichment:${contactId}`; +} + +export function buildPendingKey(conversationId: string): string { + return `pending:${conversationId}`; +} + +export function buildAssociationKey(conversationId: string): string { + return `association:${conversationId}`; +} + +// ── HubSpot API Helpers ─────────────────────────────────────────────────────── + +/** + * Generic HubSpot REST API request with Bearer-token (Private App) auth. + * Throws on non-2xx responses. + */ +export async function hubspotRequest( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + method: string, + path: string, + body?: unknown, +): Promise { + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }; + + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + const response = await makeRequest(`${HUBSPOT_API_BASE}${path}`, init); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `HubSpot API error ${response.status}: ${text || response.statusText}`, + ); + } + + return response.json() as Promise; +} + +/** + * Search HubSpot contacts by a query string using the v3 search API. + */ +export async function searchContacts( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + query: string, + limit = 20, +): Promise { + const result = await hubspotRequest>( + makeRequest, + apiKey, + "POST", + "/crm/v3/objects/contacts/search", + { + query, + limit, + properties: ["firstname", "lastname", "email", "company", "phone"], + }, + ); + return result.results; +} + +/** + * Get a single HubSpot contact by ID with standard properties. + */ +export async function getContact( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + contactId: string, +): Promise { + return hubspotRequest( + makeRequest, + apiKey, + "GET", + `/crm/v3/objects/contacts/${contactId}?properties=firstname,lastname,email,company,phone`, + ); +} + +/** + * List HubSpot deals, optionally filtered client-side by pipeline ID. + */ +export async function getDeals( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + pipelineId?: string, + limit = 20, +): Promise { + const params = new URLSearchParams({ + limit: String(limit), + properties: "dealname,dealstage,pipeline,amount,closedate", + }); + + const result = await hubspotRequest>( + makeRequest, + apiKey, + "GET", + `/crm/v3/objects/deals?${params.toString()}`, + ); + + if (pipelineId) { + return result.results.filter((d) => d.properties.pipeline === pipelineId); + } + + return result.results; +} + +/** + * Create a Note engagement object in HubSpot. + */ +export async function createNote( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + noteBody: string, +): Promise { + return hubspotRequest( + makeRequest, + apiKey, + "POST", + "/crm/v3/objects/notes", + { + properties: { + hs_note_body: noteBody, + hs_timestamp: new Date().toISOString(), + }, + }, + ); +} + +/** + * Associate a Note with a Contact via the v3 associations batch API. + */ +export async function associateNoteWithContact( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + noteId: string, + contactId: string, +): Promise { + await hubspotRequest( + makeRequest, + apiKey, + "POST", + "/crm/v3/associations/notes/contacts/batch/create", + { + inputs: [ + { + from: { id: noteId }, + to: { id: contactId }, + type: "note_to_contact", + }, + ], + }, + ); +} + +/** + * Update the stage of a HubSpot deal via PATCH. + */ +export async function updateDealStage( + makeRequest: (url: string, opts?: RequestInit) => Promise, + apiKey: string, + dealId: string, + stage: string, +): Promise { + return hubspotRequest( + makeRequest, + apiKey, + "PATCH", + `/crm/v3/objects/deals/${dealId}`, + { properties: { dealstage: stage } }, + ); +} + +// ── AI Enrichment ───────────────────────────────────────────────────────────── + +/** + * Generate a one-paragraph CRM enrichment summary from conversation text + * using the platform's internal AI service. + */ +export async function generateEnrichmentSummary( + makeRequest: (url: string, opts?: RequestInit) => Promise, + conversationText: string, + model: string = DEFAULT_ENRICH_MODEL, +): Promise { + const prompt = `You are a CRM assistant. Based on the following AI conversation, write a concise one-paragraph summary suitable for a HubSpot contact note. Focus on the user's intent, key information shared, and any required follow-up actions.\n\nConversation:\n${conversationText}`; + + const response = await makeRequest(AI_COMPLETIONS_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + temperature: 0.3, + }), + }); + + if (!response.ok) { + throw new Error(`AI service error: ${response.status}`); + } + + const data = (await response.json()) as Record; + + const choices = data["choices"] as + | Array<{ message: { content: string } }> + | undefined; + if (choices?.[0]?.message?.content) return choices[0].message.content; + if (typeof data["content"] === "string") return data["content"] as string; + + throw new Error("Unexpected AI response shape"); +} + +// ── Plugin ──────────────────────────────────────────────────────────────────── + +export default createPlugin({ + name: "hubspot-crm", + version: "1.0.0", + description: + "Enrich HubSpot CRM contacts with AI conversation summaries. Auto-log interactions, update deal stages, and generate lead scores from chat data.", + permissions: ["network:external", "db:readwrite"], + + settings: { + hubspotApiKey: { + type: "string", + label: "HubSpot Private App Token", + encrypted: true, + }, + autoLogConversations: { + type: "boolean", + label: "Automatically log conversations to HubSpot contact timelines", + default: true, + }, + enrichModel: { + type: "select", + label: "AI Model for Contact Enrichment", + default: DEFAULT_ENRICH_MODEL, + options: [ + "gpt-4o-mini", + "gpt-4o", + "claude-3-5-haiku", + "claude-3-5-sonnet", + ], + }, + pipelineId: { + type: "string", + label: "HubSpot Pipeline ID (optional — used to filter deals)", + }, + }, + + hooks: { + // ── app:init ───────────────────────────────────────────────────────────── + "app:init": async (context: PluginContext) => { + const { api } = context; + + // ── POST /connect ─────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/connect", + auth: true, + description: "Validate and store HubSpot Private App Token", + handler: async (req, res) => { + const { apiKey } = req.body as { apiKey?: string }; + + if (!apiKey) { + res.status(400).json({ error: "apiKey is required" }); + return; + } + + // Validate by fetching one contact — cheap read operation + try { + await hubspotRequest( + api.makeRequest, + apiKey, + "GET", + "/crm/v3/objects/contacts?limit=1", + ); + } catch (err) { + res.status(400).json({ + error: `Invalid API key: ${(err as Error).message}`, + }); + return; + } + + const config: ConnectionConfig = { apiKey, connectedAt: Date.now() }; + await api.db.set(CONNECTION_KEY, config); + res.status(200).json({ connected: true }); + }, + }); + + // ── GET /contacts ─────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/contacts", + auth: true, + description: "Search HubSpot contacts by query string", + handler: async (req, res) => { + const apiKey = (api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) { + res.status(400).json({ error: "HubSpot API key not configured" }); + return; + } + + const q = req.query["q"] ?? ""; + const limit = Math.min( + parseInt(req.query["limit"] ?? "20", 10) || 20, + 100, + ); + + try { + const contacts = await searchContacts( + api.makeRequest, + apiKey, + q, + limit, + ); + res.status(200).json({ contacts }); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + } + }, + }); + + // ── GET /contacts/:id ─────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/contacts/:id", + auth: true, + description: "Get a single HubSpot contact by ID", + handler: async (req, res) => { + const apiKey = (api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) { + res.status(400).json({ error: "HubSpot API key not configured" }); + return; + } + + const { id } = req.params; + + try { + const contact = await getContact(api.makeRequest, apiKey, id); + res.status(200).json({ contact }); + } catch (err) { + const msg = (err as Error).message; + if (msg.includes("404")) { + res.status(404).json({ error: "Contact not found" }); + } else { + res.status(502).json({ error: msg }); + } + } + }, + }); + + // ── POST /contacts/:id/enrich ─────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/contacts/:id/enrich", + auth: true, + description: + "Generate an AI summary from conversation text and add it as a HubSpot note", + handler: async (req, res) => { + const apiKey = (api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) { + res.status(400).json({ error: "HubSpot API key not configured" }); + return; + } + + const { id: contactId } = req.params; + const { conversationText, conversationId } = req.body as { + conversationText?: string; + conversationId?: string; + }; + + if (!conversationText) { + res.status(400).json({ error: "conversationText is required" }); + return; + } + + const model = + (api.getConfig("enrichModel") as string) ?? DEFAULT_ENRICH_MODEL; + + try { + const summary = await generateEnrichmentSummary( + api.makeRequest, + conversationText, + model, + ); + + const note = await createNote(api.makeRequest, apiKey, summary); + await associateNoteWithContact( + api.makeRequest, + apiKey, + note.id, + contactId, + ); + + const enrichmentRecord: EnrichmentRecord = { + contactId, + summary, + model, + generatedAt: Date.now(), + }; + await api.db.set(buildEnrichmentKey(contactId), enrichmentRecord); + + if (conversationId) { + const interaction: ContactInteractionRecord = { + hubspotContactId: contactId, + conversationId, + userId: "", + noteId: note.id, + loggedAt: Date.now(), + }; + await api.db.set( + buildContactKey(contactId, conversationId), + interaction, + ); + } + + res.status(200).json({ summary, noteId: note.id }); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + } + }, + }); + + // ── GET /deals ────────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/deals", + auth: true, + description: "List HubSpot deals, optionally filtered by pipeline", + handler: async (req, res) => { + const apiKey = (api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) { + res.status(400).json({ error: "HubSpot API key not configured" }); + return; + } + + const limit = Math.min( + parseInt(req.query["limit"] ?? "20", 10) || 20, + 100, + ); + const pipelineId = + req.query["pipelineId"] || + (api.getConfig("pipelineId") as string) || + undefined; + + try { + const deals = await getDeals( + api.makeRequest, + apiKey, + pipelineId, + limit, + ); + res.status(200).json({ deals }); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + } + }, + }); + + // ── POST /deals/:id/update-stage ──────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/deals/:id/update-stage", + auth: true, + description: "Update the stage of a HubSpot deal", + handler: async (req, res) => { + const apiKey = (api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) { + res.status(400).json({ error: "HubSpot API key not configured" }); + return; + } + + const { id: dealId } = req.params; + const { stage } = req.body as { stage?: string }; + + if (!stage) { + res.status(400).json({ error: "stage is required" }); + return; + } + + try { + const deal = await updateDealStage( + api.makeRequest, + apiKey, + dealId, + stage, + ); + res.status(200).json({ deal }); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + } + }, + }); + }, + + // ── conversation:end ────────────────────────────────────────────────────── + "conversation:end": async (context: PluginContext) => { + const autoLog = + (context.api.getConfig("autoLogConversations") as boolean) ?? true; + if (!autoLog) return; + + const conversationId = (context as unknown as Record)[ + "conversationId" + ] as string | undefined; + if (!conversationId) return; + + // Always queue a pending record for later manual linking + const pending: PendingInteractionRecord = { + conversationId, + userId: context.userId, + queuedAt: Date.now(), + }; + await context.api.db.set(buildPendingKey(conversationId), pending); + + // If API key is configured, check for a stored contact association + const apiKey = (context.api.getConfig("hubspotApiKey") as string) ?? ""; + if (!apiKey) return; + + const association = (await context.api.db.get( + buildAssociationKey(conversationId), + )) as { hubspotContactId: string } | null; + if (!association?.hubspotContactId) return; + + try { + const noteBody = [ + `Conversation logged by Agentbase`, + `Conversation ID: ${conversationId}`, + `User ID: ${context.userId}`, + `Timestamp: ${new Date().toISOString()}`, + ].join("\n"); + + const note = await createNote( + context.api.makeRequest, + apiKey, + noteBody, + ); + await associateNoteWithContact( + context.api.makeRequest, + apiKey, + note.id, + association.hubspotContactId, + ); + + const record: ContactInteractionRecord = { + hubspotContactId: association.hubspotContactId, + conversationId, + userId: context.userId, + noteId: note.id, + loggedAt: Date.now(), + }; + await context.api.db.set( + buildContactKey(association.hubspotContactId, conversationId), + record, + ); + } catch { + // Silent failure — logging must not interrupt the conversation flow + } + }, + }, +}); diff --git a/packages/plugins/official/hubspot-crm/tsconfig.json b/packages/plugins/official/hubspot-crm/tsconfig.json new file mode 100644 index 0000000..5628f37 --- /dev/null +++ b/packages/plugins/official/hubspot-crm/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2022", "DOM"], + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "ignoreDeprecations": "6.0", + "types": ["node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0104670..6cdd37f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,7 +269,7 @@ importers: version: 10.4.24(postcss@8.5.6) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11) + version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) jest-environment-jsdom: specifier: ^30.2.0 version: 30.2.0 @@ -284,7 +284,7 @@ importers: version: 1.0.7(tailwindcss@3.4.19) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -373,10 +373,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -392,10 +392,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -414,10 +414,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -436,14 +436,36 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 + packages/plugins/official/hubspot-crm: + dependencies: + '@agentbase/plugin-sdk': + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^20.0.0 + version: 20.19.33 + jest: + specifier: ^29.5.0 + version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/plugins/official/openrouter-gateway: dependencies: '@agentbase/plugin-sdk': @@ -458,10 +480,10 @@ importers: version: 20.19.33 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.33) + version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -480,10 +502,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -502,10 +524,10 @@ importers: version: 20.19.33 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.33) + version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -524,10 +546,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -543,10 +565,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -564,10 +586,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -6386,6 +6408,41 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.11 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 @@ -8380,13 +8437,13 @@ snapshots: optionalDependencies: typescript: 5.7.2 - create-jest@29.7.0(@types/node@20.19.33): + create-jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.33) + jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -9602,54 +9659,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0: + jest-cli@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + create-jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-cli@29.7.0(@types/node@20.19.33): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.33) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.33) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-cli@29.7.0(@types/node@22.19.11): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -9678,9 +9697,9 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.5.2): + jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 @@ -9697,26 +9716,38 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 jest-util: 29.7.0 jest-validate: 29.7.0 - yargs: 17.7.2 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.33 + ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3) transitivePeerDependencies: - - '@types/node' - babel-plugin-macros - supports-color - - ts-node - jest-config@29.7.0(@types/node@20.19.33): + jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -9741,7 +9772,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.19.33 + '@types/node': 22.19.11 + ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10142,36 +10174,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0: + jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@29.7.0(@types/node@20.19.33): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.33) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@29.7.0(@types/node@22.19.11): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.19.11) + jest-cli: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -10190,18 +10198,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@25.5.2): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.5.2) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) @@ -12279,12 +12275,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.33) + jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -12319,26 +12315,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.19.11) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 30.3.0 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 30.3.0 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -12359,46 +12335,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@25.5.2) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 30.3.0 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 30.3.0 - - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 30.3.0 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 30.3.0 - ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1