From 8a769ed3b3ac0f11b7b5a0c351c136531490e363 Mon Sep 17 00:00:00 2001 From: Nico Sammito Date: Thu, 23 Jul 2026 19:54:41 +0200 Subject: [PATCH] feat: add hubspot action (#41) --- .gitlab-ci.yml | 1 + README.md | 1 + actions/hubspot-action/.gitignore | 3 + actions/hubspot-action/Dockerfile | 13 + actions/hubspot-action/README.md | 81 ++++++ actions/hubspot-action/package.json | 25 ++ .../src/data_types/hubspotCompany.ts | 12 + .../src/data_types/hubspotContact.ts | 12 + .../src/data_types/hubspotDeal.ts | 12 + .../src/data_types/hubspotNote.ts | 12 + .../src/data_types/hubspotObjectBase.ts | 24 ++ .../src/data_types/hubspotSearchResult.ts | 15 + .../src/data_types/hubspotWebhookEvent.ts | 32 +++ .../events/hubspotContactCreatedWebhook.ts | 23 ++ .../src/events/hubspotDealCreatedWebhook.ts | 23 ++ .../events/hubspotDealStageChangedWebhook.ts | 23 ++ .../src/events/hubspotFormSubmittedWebhook.ts | 23 ++ .../src/functions/hubspotAddNoteFunction.ts | 26 ++ .../src/functions/hubspotAssociateFunction.ts | 27 ++ .../functions/hubspotCreateCompanyFunction.ts | 26 ++ .../functions/hubspotCreateContactFunction.ts | 26 ++ .../functions/hubspotCreateDealFunction.ts | 29 ++ .../hubspotGetContactByEmailFunction.ts | 25 ++ .../functions/hubspotSearchObjectsFunction.ts | 26 ++ .../functions/hubspotUpdateContactFunction.ts | 26 ++ .../functions/hubspotUpdateDealFunction.ts | 26 ++ actions/hubspot-action/src/helpers.ts | 262 ++++++++++++++++++ actions/hubspot-action/src/index.ts | 106 +++++++ actions/hubspot-action/test/index.test.ts | 71 +++++ actions/hubspot-action/tsconfig.json | 23 ++ actions/hubspot-action/vite.config.ts | 19 ++ 31 files changed, 1053 insertions(+) create mode 100644 actions/hubspot-action/.gitignore create mode 100644 actions/hubspot-action/Dockerfile create mode 100644 actions/hubspot-action/README.md create mode 100644 actions/hubspot-action/package.json create mode 100644 actions/hubspot-action/src/data_types/hubspotCompany.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotContact.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotDeal.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotNote.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotObjectBase.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotSearchResult.ts create mode 100644 actions/hubspot-action/src/data_types/hubspotWebhookEvent.ts create mode 100644 actions/hubspot-action/src/events/hubspotContactCreatedWebhook.ts create mode 100644 actions/hubspot-action/src/events/hubspotDealCreatedWebhook.ts create mode 100644 actions/hubspot-action/src/events/hubspotDealStageChangedWebhook.ts create mode 100644 actions/hubspot-action/src/events/hubspotFormSubmittedWebhook.ts create mode 100644 actions/hubspot-action/src/functions/hubspotAddNoteFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotAssociateFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotCreateCompanyFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotCreateContactFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotCreateDealFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotGetContactByEmailFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotSearchObjectsFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotUpdateContactFunction.ts create mode 100644 actions/hubspot-action/src/functions/hubspotUpdateDealFunction.ts create mode 100644 actions/hubspot-action/src/helpers.ts create mode 100644 actions/hubspot-action/src/index.ts create mode 100644 actions/hubspot-action/test/index.test.ts create mode 100644 actions/hubspot-action/tsconfig.json create mode 100644 actions/hubspot-action/vite.config.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 26bccc5..ce28f1b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,6 +25,7 @@ include: - woocommerce-action - shopware-action - twilio-action + - hubspot-action test-node: image: node:24.10.0 diff --git a/README.md b/README.md index 4cf5dca..65d5a2f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The "central" place for all actions — a monorepo containing integrations provi | Action | Description | |--------|-------------| | [GLS](docs/Actions/GLS/overview.md) | GLS ShipIT integration for creating and managing shipments | +| HubSpot | HubSpot CRM integration: webhooks for contacts/deals/companies plus create, update, search, associate, and note functions | ## ENV diff --git a/actions/hubspot-action/.gitignore b/actions/hubspot-action/.gitignore new file mode 100644 index 0000000..ddc3c2a --- /dev/null +++ b/actions/hubspot-action/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules/ +dist/ diff --git a/actions/hubspot-action/Dockerfile b/actions/hubspot-action/Dockerfile new file mode 100644 index 0000000..5871953 --- /dev/null +++ b/actions/hubspot-action/Dockerfile @@ -0,0 +1,13 @@ +FROM node:24.10.0-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ + +RUN npm install + +COPY . . + +RUN npm run build + +CMD ["npm", "run", "start"] diff --git a/actions/hubspot-action/README.md b/actions/hubspot-action/README.md new file mode 100644 index 0000000..30cb960 --- /dev/null +++ b/actions/hubspot-action/README.md @@ -0,0 +1,81 @@ +# HubSpot Action + +HubSpot CRM integration for the **Hercules** automation platform. Lets flows +react to HubSpot webhook events and create, update, search, associate, and +annotate CRM records through the official +[`@hubspot/api-client`](https://www.npmjs.com/package/@hubspot/api-client) SDK +(HubSpot CRM v3 API). + +## Configuration + +Configured via the action's `ConfigurationDefinition`s: + +| Identifier | Type | Required | Description | +|------------|------|----------|-------------| +| `access_token` | TEXT | yes | Private-app access token (Bearer) for the HubSpot CRM API. | +| `app_id` | TEXT | no | HubSpot app id, used when managing webhook subscriptions. | +| `client_secret` | TEXT | no | HubSpot app client secret, used to validate the `X-HubSpot-Signature` on inbound webhooks. | + +Beyond the shared Hercules variables (`HERCULES_AUTH_TOKEN`, +`HERCULES_AQUILA_URL`, `HERCULES_ACTION_ID`, `HERCULES_SDK_VERSION`), the only +provider-specific credential required is the HubSpot private-app access token. + +### Creating a private app + +1. In HubSpot, go to **Settings → Integrations → Private Apps**. +2. Create a private app and grant the CRM scopes you need + (`crm.objects.contacts.*`, `crm.objects.deals.*`, `crm.objects.companies.*`, + `crm.schemas.*`, and note/engagement scopes for `hubspotAddNote`). +3. Copy the access token into the action's `access_token` config. + +### Webhooks (triggers) + +The triggers use the shared `Rest` (webhook) event pattern. Point a HubSpot app +**webhook subscription** at the URL generated for the trigger. HubSpot delivers +a JSON array of change events; each event is exposed to the flow as a +`HUBSPOT_WEBHOOK_EVENT`. To verify signatures, configure `client_secret`. + +## Triggers + +| Identifier | Fires on | +|------------|----------| +| `HubSpotContactCreatedWebhook` | `contact.creation` | +| `HubSpotDealCreatedWebhook` | `deal.creation` | +| `HubSpotDealStageChangedWebhook` | `deal.propertyChange` on `dealstage` | +| `HubSpotFormSubmittedWebhook` | form submission (lead capture) | + +## Functions + +| Identifier | Signature | Description | +|------------|-----------|-------------| +| `hubspotCreateContact` | `(Email, PropertiesJson?): HUBSPOT_CONTACT` | Create a contact. | +| `hubspotUpdateContact` | `(ContactId, PropertiesJson): HUBSPOT_CONTACT` | Update a contact. | +| `hubspotGetContactByEmail` | `(Email): HUBSPOT_CONTACT` | Look up a contact by email. | +| `hubspotCreateDeal` | `(Name, Pipeline, Stage, Amount?, PropertiesJson?): HUBSPOT_DEAL` | Create a deal. | +| `hubspotUpdateDeal` | `(DealId, PropertiesJson): HUBSPOT_DEAL` | Update a deal. | +| `hubspotCreateCompany` | `(Name, PropertiesJson?): HUBSPOT_COMPANY` | Create a company. | +| `hubspotAddNote` | `(Body, PropertiesJson?): HUBSPOT_NOTE` | Create a note engagement. | +| `hubspotAssociate` | `(FromObjectType, FromId, ToObjectType, ToId): BOOLEAN` | Create the default association between two objects. | +| `hubspotSearchObjects` | `(ObjectType, Query): HUBSPOT_SEARCH_RESULT` | Full-text search a CRM object type. | + +### Property maps + +HubSpot CRM properties are a dynamic key/value bag, so functions accept extra +properties as a **JSON object string** (e.g. +`{"firstname":"Ada","lifecyclestage":"lead"}`) rather than a fixed set of typed +parameters. Values are coerced to strings, matching the HubSpot API. + +## Data types + +`HUBSPOT_CONTACT`, `HUBSPOT_DEAL`, `HUBSPOT_COMPANY`, `HUBSPOT_NOTE` (all share +the SDK's `SimplePublicObject` envelope: `id`, `properties`, timestamps, +`archived`), plus `HUBSPOT_SEARCH_RESULT` and `HUBSPOT_WEBHOOK_EVENT`. + +## Development + +```bash +npm install +npm run typecheck +npm run build +npm run test +``` diff --git a/actions/hubspot-action/package.json b/actions/hubspot-action/package.json new file mode 100644 index 0000000..dc734eb --- /dev/null +++ b/actions/hubspot-action/package.json @@ -0,0 +1,25 @@ +{ + "name": "@code0-tech/hubspot-action", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "typecheck": "tsc --noEmit", + "build": "vite build", + "test": "vitest run", + "start": "node dist/index.js" + }, + "dependencies": { + "@code0-tech/hercules": "^1.1.1", + "@hubspot/api-client": "^14.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.9.3", + "vite": "^8.0.3", + "vitest": "^4.1.10" + } +} diff --git a/actions/hubspot-action/src/data_types/hubspotCompany.ts b/actions/hubspot-action/src/data_types/hubspotCompany.ts new file mode 100644 index 0000000..a73b2e4 --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotCompany.ts @@ -0,0 +1,12 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; +import { hubSpotObjectSchema } from "./hubspotObjectBase.js"; + +export const HubSpotCompanySchema = hubSpotObjectSchema(); +export type HubSpotCompany = z.infer; + +@Identifier("HUBSPOT_COMPANY") +@Name({ code: "en-US", content: "HubSpot company" }) +@DisplayMessage({ code: "en-US", content: "HubSpot company" }) +@Schema(HubSpotCompanySchema) +export class HubSpotCompanyDataType {} diff --git a/actions/hubspot-action/src/data_types/hubspotContact.ts b/actions/hubspot-action/src/data_types/hubspotContact.ts new file mode 100644 index 0000000..49d88dd --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotContact.ts @@ -0,0 +1,12 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; +import { hubSpotObjectSchema } from "./hubspotObjectBase.js"; + +export const HubSpotContactSchema = hubSpotObjectSchema(); +export type HubSpotContact = z.infer; + +@Identifier("HUBSPOT_CONTACT") +@Name({ code: "en-US", content: "HubSpot contact" }) +@DisplayMessage({ code: "en-US", content: "HubSpot contact" }) +@Schema(HubSpotContactSchema) +export class HubSpotContactDataType {} diff --git a/actions/hubspot-action/src/data_types/hubspotDeal.ts b/actions/hubspot-action/src/data_types/hubspotDeal.ts new file mode 100644 index 0000000..2cd2c4f --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotDeal.ts @@ -0,0 +1,12 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; +import { hubSpotObjectSchema } from "./hubspotObjectBase.js"; + +export const HubSpotDealSchema = hubSpotObjectSchema(); +export type HubSpotDeal = z.infer; + +@Identifier("HUBSPOT_DEAL") +@Name({ code: "en-US", content: "HubSpot deal" }) +@DisplayMessage({ code: "en-US", content: "HubSpot deal" }) +@Schema(HubSpotDealSchema) +export class HubSpotDealDataType {} diff --git a/actions/hubspot-action/src/data_types/hubspotNote.ts b/actions/hubspot-action/src/data_types/hubspotNote.ts new file mode 100644 index 0000000..2c94ffc --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotNote.ts @@ -0,0 +1,12 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; +import { hubSpotObjectSchema } from "./hubspotObjectBase.js"; + +export const HubSpotNoteSchema = hubSpotObjectSchema(); +export type HubSpotNote = z.infer; + +@Identifier("HUBSPOT_NOTE") +@Name({ code: "en-US", content: "HubSpot note" }) +@DisplayMessage({ code: "en-US", content: "HubSpot note" }) +@Schema(HubSpotNoteSchema) +export class HubSpotNoteDataType {} diff --git a/actions/hubspot-action/src/data_types/hubspotObjectBase.ts b/actions/hubspot-action/src/data_types/hubspotObjectBase.ts new file mode 100644 index 0000000..53718d4 --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotObjectBase.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Base shape shared by all HubSpot CRM objects returned by the official + * `@hubspot/api-client` SDK (its `SimplePublicObject`): a stable `id`, a flat + * bag of string `properties`, lifecycle timestamps, and an `archived` flag. + * + * HubSpot CRM objects (contacts, deals, companies, notes, ...) all share this + * envelope and differ only in which property keys are populated, so we model + * the SDK's response type once and reuse it for every object data type rather + * than duplicating a near-identical schema per object. + */ +export const hubSpotObjectSchema = () => + z.object({ + id: z.string().describe("The unique HubSpot object id."), + properties: z + .record(z.string(), z.string().nullable()) + .describe("Flat map of HubSpot property names to their (string) values."), + createdAt: z.string().nullable().optional().describe("ISO-8601 creation timestamp."), + updatedAt: z.string().nullable().optional().describe("ISO-8601 last-update timestamp."), + archived: z.boolean().nullable().optional().describe("Whether the object is archived."), + }); + +export type HubSpotObject = z.infer>; diff --git a/actions/hubspot-action/src/data_types/hubspotSearchResult.ts b/actions/hubspot-action/src/data_types/hubspotSearchResult.ts new file mode 100644 index 0000000..61dc7d6 --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotSearchResult.ts @@ -0,0 +1,15 @@ +import { DisplayMessage, Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; +import { hubSpotObjectSchema } from "./hubspotObjectBase.js"; + +export const HubSpotSearchResultSchema = z.object({ + total: z.number().describe("Total number of objects matching the search."), + results: z.array(hubSpotObjectSchema()).describe("The page of matching objects."), +}); +export type HubSpotSearchResult = z.infer; + +@Identifier("HUBSPOT_SEARCH_RESULT") +@Name({ code: "en-US", content: "HubSpot search result" }) +@DisplayMessage({ code: "en-US", content: "HubSpot search result (${total} matches)" }) +@Schema(HubSpotSearchResultSchema) +export class HubSpotSearchResultDataType {} diff --git a/actions/hubspot-action/src/data_types/hubspotWebhookEvent.ts b/actions/hubspot-action/src/data_types/hubspotWebhookEvent.ts new file mode 100644 index 0000000..e067d32 --- /dev/null +++ b/actions/hubspot-action/src/data_types/hubspotWebhookEvent.ts @@ -0,0 +1,32 @@ +import { Identifier, Name, Schema } from "@code0-tech/hercules"; +import { z } from "zod"; + +/** + * A single HubSpot app-webhook change event. HubSpot delivers webhook + * subscriptions as a JSON array of these event objects; the Rest trigger + * exposes the individual event to the flow. The schema is intentionally + * permissive (most fields optional) because the exact set of fields depends on + * the `subscriptionType` (property-change events carry `propertyName` / + * `propertyValue`, creation events do not). + * See https://developers.hubspot.com/docs/api/webhooks + */ +export const HubSpotWebhookEventSchema = z.object({ + eventId: z.number().optional(), + subscriptionId: z.number().optional(), + portalId: z.number().optional(), + appId: z.number().optional(), + occurredAt: z.number().optional(), + subscriptionType: z.string().optional(), + attemptNumber: z.number().optional(), + objectId: z.number().optional(), + changeSource: z.string().optional(), + changeFlag: z.string().optional(), + propertyName: z.string().optional(), + propertyValue: z.string().optional(), +}); +export type HubSpotWebhookEvent = z.infer; + +@Identifier("HUBSPOT_WEBHOOK_EVENT") +@Name({ code: "en-US", content: "HubSpot webhook event" }) +@Schema(HubSpotWebhookEventSchema) +export class HubSpotWebhookEventDataType {} diff --git a/actions/hubspot-action/src/events/hubspotContactCreatedWebhook.ts b/actions/hubspot-action/src/events/hubspotContactCreatedWebhook.ts new file mode 100644 index 0000000..450809d --- /dev/null +++ b/actions/hubspot-action/src/events/hubspotContactCreatedWebhook.ts @@ -0,0 +1,23 @@ +import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules"; + +@Identifier("HubSpotContactCreatedWebhook") +@DisplayIcon("simple:hubspot") +@Name({ code: "en-US", content: "HubSpot contact created" }) +@Description({ code: "en-US", content: "Triggered when a new contact is created in HubSpot (subscription type contact.creation)." }) +@DisplayMessage({ code: "en-US", content: "HubSpot contact created on ${httpURL}" }) +@Signature("(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE, input_schema?: HUBSPOT_WEBHOOK_EVENT): REST_ADAPTER_INPUT") +@EventSetting({ + identifier: "input_schema", + hidden: true +}) +@EventSetting({ + identifier: "httpMethod", + hidden: true, + defaultValue: "POST" +}) +@EventSetting({ + identifier: "httpSchema", + hidden: true, + defaultValue: "application/json" +}) +export class HubSpotContactCreatedWebhook extends Rest {} diff --git a/actions/hubspot-action/src/events/hubspotDealCreatedWebhook.ts b/actions/hubspot-action/src/events/hubspotDealCreatedWebhook.ts new file mode 100644 index 0000000..ad6bc22 --- /dev/null +++ b/actions/hubspot-action/src/events/hubspotDealCreatedWebhook.ts @@ -0,0 +1,23 @@ +import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules"; + +@Identifier("HubSpotDealCreatedWebhook") +@DisplayIcon("simple:hubspot") +@Name({ code: "en-US", content: "HubSpot deal created" }) +@Description({ code: "en-US", content: "Triggered when a new deal is created in HubSpot (subscription type deal.creation)." }) +@DisplayMessage({ code: "en-US", content: "HubSpot deal created on ${httpURL}" }) +@Signature("(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE, input_schema?: HUBSPOT_WEBHOOK_EVENT): REST_ADAPTER_INPUT") +@EventSetting({ + identifier: "input_schema", + hidden: true +}) +@EventSetting({ + identifier: "httpMethod", + hidden: true, + defaultValue: "POST" +}) +@EventSetting({ + identifier: "httpSchema", + hidden: true, + defaultValue: "application/json" +}) +export class HubSpotDealCreatedWebhook extends Rest {} diff --git a/actions/hubspot-action/src/events/hubspotDealStageChangedWebhook.ts b/actions/hubspot-action/src/events/hubspotDealStageChangedWebhook.ts new file mode 100644 index 0000000..0bee297 --- /dev/null +++ b/actions/hubspot-action/src/events/hubspotDealStageChangedWebhook.ts @@ -0,0 +1,23 @@ +import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules"; + +@Identifier("HubSpotDealStageChangedWebhook") +@DisplayIcon("simple:hubspot") +@Name({ code: "en-US", content: "HubSpot deal stage changed" }) +@Description({ code: "en-US", content: "Triggered when a deal's stage changes in HubSpot (subscription type deal.propertyChange on dealstage)." }) +@DisplayMessage({ code: "en-US", content: "HubSpot deal stage changed on ${httpURL}" }) +@Signature("(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE, input_schema?: HUBSPOT_WEBHOOK_EVENT): REST_ADAPTER_INPUT") +@EventSetting({ + identifier: "input_schema", + hidden: true +}) +@EventSetting({ + identifier: "httpMethod", + hidden: true, + defaultValue: "POST" +}) +@EventSetting({ + identifier: "httpSchema", + hidden: true, + defaultValue: "application/json" +}) +export class HubSpotDealStageChangedWebhook extends Rest {} diff --git a/actions/hubspot-action/src/events/hubspotFormSubmittedWebhook.ts b/actions/hubspot-action/src/events/hubspotFormSubmittedWebhook.ts new file mode 100644 index 0000000..e4d1dad --- /dev/null +++ b/actions/hubspot-action/src/events/hubspotFormSubmittedWebhook.ts @@ -0,0 +1,23 @@ +import { Description, DisplayIcon, DisplayMessage, EventSetting, Identifier, Name, Rest, Signature } from "@code0-tech/hercules"; + +@Identifier("HubSpotFormSubmittedWebhook") +@DisplayIcon("simple:hubspot") +@Name({ code: "en-US", content: "HubSpot form submitted" }) +@Description({ code: "en-US", content: "Triggered when a HubSpot form is submitted (lead capture). Delivered as a HubSpot webhook event." }) +@DisplayMessage({ code: "en-US", content: "HubSpot form submitted on ${httpURL}" }) +@Signature("(httpSchema: HTTP_SCHEMA, httpURL: HTTP_URL, httpMethod: HTTP_METHOD, httpAuth: A, httpAuthValue: REST_AUTH_VALUE, input_schema?: HUBSPOT_WEBHOOK_EVENT): REST_ADAPTER_INPUT") +@EventSetting({ + identifier: "input_schema", + hidden: true +}) +@EventSetting({ + identifier: "httpMethod", + hidden: true, + defaultValue: "POST" +}) +@EventSetting({ + identifier: "httpSchema", + hidden: true, + defaultValue: "application/json" +}) +export class HubSpotFormSubmittedWebhook extends Rest {} diff --git a/actions/hubspot-action/src/functions/hubspotAddNoteFunction.ts b/actions/hubspot-action/src/functions/hubspotAddNoteFunction.ts new file mode 100644 index 0000000..d3b2e0c --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotAddNoteFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { addNote } from "../helpers.js"; +import { HubSpotNote } from "../data_types/hubspotNote.js"; + +@Identifier("hubspotAddNote") +@DisplayIcon("simple:hubspot") +@Signature("(Body: string, PropertiesJson?: string): HUBSPOT_NOTE") +@Name({ code: "en-US", content: "Add note" }) +@DisplayMessage({ code: "en-US", content: "Add HubSpot note" }) +@Documentation({ code: "en-US", content: "Creates a HubSpot note engagement with the given body. The note timestamp defaults to now. Extra properties may be passed as a JSON object string." }) +@Description({ code: "en-US", content: "Creates a HubSpot note." }) +@Parameter({ runtimeName: "Body", name: [{ code: "en-US", content: "Body" }], description: [{ code: "en-US", content: "The note text (hs_note_body)." }] }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "Optional JSON object of additional note properties." }], optional: true }) +export class HubSpotAddNoteFunction { + async run(context: FunctionContext, Body: string, PropertiesJson?: string): Promise { + try { + return await addNote(Body, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_CREATING_HUBSPOT_NOTE", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotAssociateFunction.ts b/actions/hubspot-action/src/functions/hubspotAssociateFunction.ts new file mode 100644 index 0000000..e87c4a0 --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotAssociateFunction.ts @@ -0,0 +1,27 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { associate } from "../helpers.js"; + +@Identifier("hubspotAssociate") +@DisplayIcon("simple:hubspot") +@Signature("(FromObjectType: string, FromId: string, ToObjectType: string, ToId: string): BOOLEAN") +@Name({ code: "en-US", content: "Associate objects" }) +@DisplayMessage({ code: "en-US", content: "Associate HubSpot ${FromObjectType} ${FromId} with ${ToObjectType} ${ToId}" }) +@Documentation({ code: "en-US", content: "Creates the default association between two HubSpot CRM objects (e.g. contact and deal) using the v4 associations API." }) +@Description({ code: "en-US", content: "Associates two HubSpot CRM objects." }) +@Parameter({ runtimeName: "FromObjectType", name: [{ code: "en-US", content: "From object type" }], description: [{ code: "en-US", content: "The source object type (e.g. contacts, deals, companies)." }] }) +@Parameter({ runtimeName: "FromId", name: [{ code: "en-US", content: "From id" }], description: [{ code: "en-US", content: "The source object id." }] }) +@Parameter({ runtimeName: "ToObjectType", name: [{ code: "en-US", content: "To object type" }], description: [{ code: "en-US", content: "The target object type (e.g. contacts, deals, companies)." }] }) +@Parameter({ runtimeName: "ToId", name: [{ code: "en-US", content: "To id" }], description: [{ code: "en-US", content: "The target object id." }] }) +export class HubSpotAssociateFunction { + async run(context: FunctionContext, FromObjectType: string, FromId: string, ToObjectType: string, ToId: string): Promise { + try { + return await associate(FromObjectType, FromId, ToObjectType, ToId, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_ASSOCIATING_HUBSPOT_OBJECTS", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotCreateCompanyFunction.ts b/actions/hubspot-action/src/functions/hubspotCreateCompanyFunction.ts new file mode 100644 index 0000000..3fe3518 --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotCreateCompanyFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { createCompany } from "../helpers.js"; +import { HubSpotCompany } from "../data_types/hubspotCompany.js"; + +@Identifier("hubspotCreateCompany") +@DisplayIcon("simple:hubspot") +@Signature("(Name: string, PropertiesJson?: string): HUBSPOT_COMPANY") +@Name({ code: "en-US", content: "Create company" }) +@DisplayMessage({ code: "en-US", content: "Create HubSpot company ${Name}" }) +@Documentation({ code: "en-US", content: "Creates a new HubSpot company. Pass additional properties (e.g. domain, industry) as a JSON object string." }) +@Description({ code: "en-US", content: "Creates a new HubSpot company." }) +@Parameter({ runtimeName: "Name", name: [{ code: "en-US", content: "Name" }], description: [{ code: "en-US", content: "The company name." }] }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "Optional JSON object of additional company properties." }], optional: true }) +export class HubSpotCreateCompanyFunction { + async run(context: FunctionContext, Name: string, PropertiesJson?: string): Promise { + try { + return await createCompany(Name, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_CREATING_HUBSPOT_COMPANY", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotCreateContactFunction.ts b/actions/hubspot-action/src/functions/hubspotCreateContactFunction.ts new file mode 100644 index 0000000..ac0e3b4 --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotCreateContactFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { createContact } from "../helpers.js"; +import { HubSpotContact } from "../data_types/hubspotContact.js"; + +@Identifier("hubspotCreateContact") +@DisplayIcon("simple:hubspot") +@Signature("(Email: string, PropertiesJson?: string): HUBSPOT_CONTACT") +@Name({ code: "en-US", content: "Create contact" }) +@DisplayMessage({ code: "en-US", content: "Create HubSpot contact ${Email}" }) +@Documentation({ code: "en-US", content: "Creates a new contact in HubSpot with the given email. Pass additional properties as a JSON object string (e.g. {\"firstname\":\"Ada\"})." }) +@Description({ code: "en-US", content: "Creates a new HubSpot contact." }) +@Parameter({ runtimeName: "Email", name: [{ code: "en-US", content: "Email" }], description: [{ code: "en-US", content: "The email address of the contact (used as the primary identifier)." }] }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "Optional JSON object of additional HubSpot contact properties." }], optional: true }) +export class HubSpotCreateContactFunction { + async run(context: FunctionContext, Email: string, PropertiesJson?: string): Promise { + try { + return await createContact(Email, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_CREATING_HUBSPOT_CONTACT", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotCreateDealFunction.ts b/actions/hubspot-action/src/functions/hubspotCreateDealFunction.ts new file mode 100644 index 0000000..0afa59e --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotCreateDealFunction.ts @@ -0,0 +1,29 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { createDeal } from "../helpers.js"; +import { HubSpotDeal } from "../data_types/hubspotDeal.js"; + +@Identifier("hubspotCreateDeal") +@DisplayIcon("simple:hubspot") +@Signature("(Name: string, Pipeline: string, Stage: string, Amount?: string, PropertiesJson?: string): HUBSPOT_DEAL") +@Name({ code: "en-US", content: "Create deal" }) +@DisplayMessage({ code: "en-US", content: "Create HubSpot deal ${Name}" }) +@Documentation({ code: "en-US", content: "Creates a new HubSpot deal in the given pipeline and stage. Amount and extra properties are optional." }) +@Description({ code: "en-US", content: "Creates a new HubSpot deal." }) +@Parameter({ runtimeName: "Name", name: [{ code: "en-US", content: "Name" }], description: [{ code: "en-US", content: "The deal name (dealname)." }] }) +@Parameter({ runtimeName: "Pipeline", name: [{ code: "en-US", content: "Pipeline" }], description: [{ code: "en-US", content: "The pipeline id the deal belongs to." }] }) +@Parameter({ runtimeName: "Stage", name: [{ code: "en-US", content: "Stage" }], description: [{ code: "en-US", content: "The deal stage id (dealstage)." }] }) +@Parameter({ runtimeName: "Amount", name: [{ code: "en-US", content: "Amount" }], description: [{ code: "en-US", content: "Optional deal amount." }], optional: true }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "Optional JSON object of additional deal properties." }], optional: true }) +export class HubSpotCreateDealFunction { + async run(context: FunctionContext, Name: string, Pipeline: string, Stage: string, Amount?: string, PropertiesJson?: string): Promise { + try { + return await createDeal(Name, Pipeline, Stage, Amount, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_CREATING_HUBSPOT_DEAL", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotGetContactByEmailFunction.ts b/actions/hubspot-action/src/functions/hubspotGetContactByEmailFunction.ts new file mode 100644 index 0000000..3a9693d --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotGetContactByEmailFunction.ts @@ -0,0 +1,25 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { getContactByEmail } from "../helpers.js"; +import { HubSpotContact } from "../data_types/hubspotContact.js"; + +@Identifier("hubspotGetContactByEmail") +@DisplayIcon("simple:hubspot") +@Signature("(Email: string): HUBSPOT_CONTACT") +@Name({ code: "en-US", content: "Get contact by email" }) +@DisplayMessage({ code: "en-US", content: "Get HubSpot contact ${Email}" }) +@Documentation({ code: "en-US", content: "Looks up a single HubSpot contact by email using the CRM search API. Fails if no contact matches." }) +@Description({ code: "en-US", content: "Fetches a HubSpot contact by email." }) +@Parameter({ runtimeName: "Email", name: [{ code: "en-US", content: "Email" }], description: [{ code: "en-US", content: "The email address to look up." }] }) +export class HubSpotGetContactByEmailFunction { + async run(context: FunctionContext, Email: string): Promise { + try { + return await getContactByEmail(Email, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_SEARCHING_HUBSPOT_CONTACT", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotSearchObjectsFunction.ts b/actions/hubspot-action/src/functions/hubspotSearchObjectsFunction.ts new file mode 100644 index 0000000..a8a8c66 --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotSearchObjectsFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { searchObjects } from "../helpers.js"; +import { HubSpotSearchResult } from "../data_types/hubspotSearchResult.js"; + +@Identifier("hubspotSearchObjects") +@DisplayIcon("simple:hubspot") +@Signature("(ObjectType: string, Query: string): HUBSPOT_SEARCH_RESULT") +@Name({ code: "en-US", content: "Search objects" }) +@DisplayMessage({ code: "en-US", content: "Search HubSpot ${ObjectType} for ${Query}" }) +@Documentation({ code: "en-US", content: "Runs a full-text search against a HubSpot CRM object type (e.g. contacts, deals, companies) and returns up to 25 matches." }) +@Description({ code: "en-US", content: "Searches HubSpot CRM objects." }) +@Parameter({ runtimeName: "ObjectType", name: [{ code: "en-US", content: "Object type" }], description: [{ code: "en-US", content: "The CRM object type to search (e.g. contacts, deals, companies)." }] }) +@Parameter({ runtimeName: "Query", name: [{ code: "en-US", content: "Query" }], description: [{ code: "en-US", content: "The free-text search query." }] }) +export class HubSpotSearchObjectsFunction { + async run(context: FunctionContext, ObjectType: string, Query: string): Promise { + try { + return await searchObjects(ObjectType, Query, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_SEARCHING_HUBSPOT_OBJECTS", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotUpdateContactFunction.ts b/actions/hubspot-action/src/functions/hubspotUpdateContactFunction.ts new file mode 100644 index 0000000..75494f5 --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotUpdateContactFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { updateContact } from "../helpers.js"; +import { HubSpotContact } from "../data_types/hubspotContact.js"; + +@Identifier("hubspotUpdateContact") +@DisplayIcon("simple:hubspot") +@Signature("(ContactId: string, PropertiesJson: string): HUBSPOT_CONTACT") +@Name({ code: "en-US", content: "Update contact" }) +@DisplayMessage({ code: "en-US", content: "Update HubSpot contact ${ContactId}" }) +@Documentation({ code: "en-US", content: "Updates an existing HubSpot contact. Provide the properties to change as a JSON object string." }) +@Description({ code: "en-US", content: "Updates an existing HubSpot contact." }) +@Parameter({ runtimeName: "ContactId", name: [{ code: "en-US", content: "Contact id" }], description: [{ code: "en-US", content: "The HubSpot id of the contact to update." }] }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "JSON object of HubSpot contact properties to set." }] }) +export class HubSpotUpdateContactFunction { + async run(context: FunctionContext, ContactId: string, PropertiesJson: string): Promise { + try { + return await updateContact(ContactId, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_UPDATING_HUBSPOT_CONTACT", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/functions/hubspotUpdateDealFunction.ts b/actions/hubspot-action/src/functions/hubspotUpdateDealFunction.ts new file mode 100644 index 0000000..1a55ced --- /dev/null +++ b/actions/hubspot-action/src/functions/hubspotUpdateDealFunction.ts @@ -0,0 +1,26 @@ +import { + Description, DisplayIcon, DisplayMessage, Documentation, FunctionContext, + Identifier, Name, Parameter, RuntimeError, Signature, +} from "@code0-tech/hercules"; +import { updateDeal } from "../helpers.js"; +import { HubSpotDeal } from "../data_types/hubspotDeal.js"; + +@Identifier("hubspotUpdateDeal") +@DisplayIcon("simple:hubspot") +@Signature("(DealId: string, PropertiesJson: string): HUBSPOT_DEAL") +@Name({ code: "en-US", content: "Update deal" }) +@DisplayMessage({ code: "en-US", content: "Update HubSpot deal ${DealId}" }) +@Documentation({ code: "en-US", content: "Updates an existing HubSpot deal. Provide the properties to change (e.g. dealstage, amount) as a JSON object string." }) +@Description({ code: "en-US", content: "Updates an existing HubSpot deal." }) +@Parameter({ runtimeName: "DealId", name: [{ code: "en-US", content: "Deal id" }], description: [{ code: "en-US", content: "The HubSpot id of the deal to update." }] }) +@Parameter({ runtimeName: "PropertiesJson", name: [{ code: "en-US", content: "Properties (JSON)" }], description: [{ code: "en-US", content: "JSON object of HubSpot deal properties to set." }] }) +export class HubSpotUpdateDealFunction { + async run(context: FunctionContext, DealId: string, PropertiesJson: string): Promise { + try { + return await updateDeal(DealId, PropertiesJson, context); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throw new RuntimeError("ERROR_UPDATING_HUBSPOT_DEAL", error instanceof Error ? error.message : "unknown error"); + } + } +} diff --git a/actions/hubspot-action/src/helpers.ts b/actions/hubspot-action/src/helpers.ts new file mode 100644 index 0000000..98e6868 --- /dev/null +++ b/actions/hubspot-action/src/helpers.ts @@ -0,0 +1,262 @@ +import { FunctionContext, RuntimeError } from "@code0-tech/hercules"; +import { Client } from "@hubspot/api-client"; +import { HubSpotContact, HubSpotContactSchema } from "./data_types/hubspotContact.js"; +import { HubSpotDeal, HubSpotDealSchema } from "./data_types/hubspotDeal.js"; +import { HubSpotCompany, HubSpotCompanySchema } from "./data_types/hubspotCompany.js"; +import { HubSpotNote, HubSpotNoteSchema } from "./data_types/hubspotNote.js"; +import { HubSpotSearchResult, HubSpotSearchResultSchema } from "./data_types/hubspotSearchResult.js"; + +/** + * Builds an authenticated HubSpot API client from the action config. Uses a + * private-app access token (Bearer) as configured on the action. + */ +export function getClient(context: FunctionContext): Client { + const accessToken = context.matchedConfig.findConfig("access_token") as string; + if (!accessToken) { + throw new RuntimeError( + "MISSING_HUBSPOT_ACCESS_TOKEN", + "A HubSpot access_token must be configured for this action." + ); + } + return new Client({ accessToken }); +} + +/** + * Parses a JSON object string into a flat HubSpot property map. HubSpot CRM + * properties are a dynamic bag of key/value pairs, so functions accept them as + * a JSON object string (e.g. `{"firstname":"Ada","lifecyclestage":"lead"}`) + * rather than a fixed set of typed parameters. Values are coerced to strings, + * which is what the HubSpot API expects. + */ +export function parseProperties( + json: string | undefined, + field = "properties" +): Record { + if (!json || json.trim() === "") return {}; + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new RuntimeError( + "INVALID_HUBSPOT_PROPERTIES", + `The ${field} value must be a valid JSON object string.` + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new RuntimeError( + "INVALID_HUBSPOT_PROPERTIES", + `The ${field} value must be a JSON object of property name/value pairs.` + ); + } + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (value === null || value === undefined) continue; + out[key] = typeof value === "string" ? value : String(value); + } + return out; +} + +/** + * Normalises the SDK's `SimplePublicObject` (Date timestamps, optional fields) + * into the plain shape expected by the zod object schemas. + */ +function toPlainObject(obj: { + id: string; + properties: { [key: string]: string | null } | Record; + createdAt?: Date; + updatedAt?: Date; + archived?: boolean; +}) { + return { + id: obj.id, + properties: obj.properties ?? {}, + createdAt: obj.createdAt ? new Date(obj.createdAt).toISOString() : null, + updatedAt: obj.updatedAt ? new Date(obj.updatedAt).toISOString() : null, + archived: obj.archived ?? null, + }; +} + +/** Maps a HubSpot SDK/ApiException error into a stable RuntimeError. */ +export function throwHubSpotError(error: unknown, code: string): never { + const anyErr = error as { code?: number; body?: unknown; message?: string }; + const status = anyErr?.code; + const detail = + (anyErr?.body && typeof anyErr.body === "object" + ? JSON.stringify(anyErr.body) + : anyErr?.message) ?? "unknown error"; + console.error("HubSpot API error:", status, detail); + throw new RuntimeError(code, `HubSpot API error ${status ?? ""}: ${detail}`); +} + +export async function createContact( + email: string, + propertiesJson: string | undefined, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties = { email, ...parseProperties(propertiesJson) }; + try { + // `any` on the request payload only: the SDK's create input type is + // version-specific; the response is validated with zod below. + const res = await client.crm.contacts.basicApi.create({ properties, associations: [] } as any); + return HubSpotContactSchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_CREATING_HUBSPOT_CONTACT"); + } +} + +export async function updateContact( + contactId: string, + propertiesJson: string, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties = parseProperties(propertiesJson); + try { + const res = await client.crm.contacts.basicApi.update(contactId, { properties } as any); + return HubSpotContactSchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_UPDATING_HUBSPOT_CONTACT"); + } +} + +export async function getContactByEmail( + email: string, + context: FunctionContext +): Promise { + const client = getClient(context); + const searchRequest: any = { + filterGroups: [{ filters: [{ propertyName: "email", operator: "EQ", value: email }] }], + properties: ["email", "firstname", "lastname", "lifecyclestage"], + limit: 1, + }; + try { + const res = await client.crm.contacts.searchApi.doSearch(searchRequest); + const first = res.results?.[0]; + if (!first) { + throw new RuntimeError( + "HUBSPOT_CONTACT_NOT_FOUND", + `No HubSpot contact found with email ${email}.` + ); + } + return HubSpotContactSchema.parse(toPlainObject(first)); + } catch (error) { + if (error instanceof RuntimeError) throw error; + throwHubSpotError(error, "ERROR_SEARCHING_HUBSPOT_CONTACT"); + } +} + +export async function createDeal( + name: string, + pipeline: string, + stage: string, + amount: string | undefined, + propertiesJson: string | undefined, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties: Record = { + dealname: name, + pipeline, + dealstage: stage, + ...(amount ? { amount } : {}), + ...parseProperties(propertiesJson), + }; + try { + const res = await client.crm.deals.basicApi.create({ properties, associations: [] } as any); + return HubSpotDealSchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_CREATING_HUBSPOT_DEAL"); + } +} + +export async function updateDeal( + dealId: string, + propertiesJson: string, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties = parseProperties(propertiesJson); + try { + const res = await client.crm.deals.basicApi.update(dealId, { properties } as any); + return HubSpotDealSchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_UPDATING_HUBSPOT_DEAL"); + } +} + +export async function createCompany( + name: string, + propertiesJson: string | undefined, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties = { name, ...parseProperties(propertiesJson) }; + try { + const res = await client.crm.companies.basicApi.create({ properties, associations: [] } as any); + return HubSpotCompanySchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_CREATING_HUBSPOT_COMPANY"); + } +} + +export async function addNote( + body: string, + propertiesJson: string | undefined, + context: FunctionContext +): Promise { + const client = getClient(context); + const properties: Record = { + hs_note_body: body, + hs_timestamp: new Date().toISOString(), + ...parseProperties(propertiesJson), + }; + try { + const res = await client.crm.objects.notes.basicApi.create({ properties, associations: [] } as any); + return HubSpotNoteSchema.parse(toPlainObject(res)); + } catch (error) { + throwHubSpotError(error, "ERROR_CREATING_HUBSPOT_NOTE"); + } +} + +export async function associate( + fromObjectType: string, + fromId: string, + toObjectType: string, + toId: string, + context: FunctionContext +): Promise { + const client = getClient(context); + try { + // Uses the default association between the two object types (v4 + // associations API), which avoids hard-coding version-specific + // association type ids. + await client.crm.associations.v4.basicApi.createDefault( + fromObjectType, + fromId, + toObjectType, + toId + ); + return true; + } catch (error) { + throwHubSpotError(error, "ERROR_ASSOCIATING_HUBSPOT_OBJECTS"); + } +} + +export async function searchObjects( + objectType: string, + query: string, + context: FunctionContext +): Promise { + const client = getClient(context); + const searchRequest: any = { query, limit: 25 }; + try { + const res = await client.crm.objects.searchApi.doSearch(objectType, searchRequest); + return HubSpotSearchResultSchema.parse({ + total: res.total ?? res.results?.length ?? 0, + results: (res.results ?? []).map((r: any) => toPlainObject(r)), + }); + } catch (error) { + throwHubSpotError(error, "ERROR_SEARCHING_HUBSPOT_OBJECTS"); + } +} diff --git a/actions/hubspot-action/src/index.ts b/actions/hubspot-action/src/index.ts new file mode 100644 index 0000000..1935206 --- /dev/null +++ b/actions/hubspot-action/src/index.ts @@ -0,0 +1,106 @@ +import "reflect-metadata"; +import { Action, CodeZeroEvent } from "@code0-tech/hercules"; + +import { HubSpotContactDataType } from "./data_types/hubspotContact.js"; +import { HubSpotDealDataType } from "./data_types/hubspotDeal.js"; +import { HubSpotCompanyDataType } from "./data_types/hubspotCompany.js"; +import { HubSpotNoteDataType } from "./data_types/hubspotNote.js"; +import { HubSpotSearchResultDataType } from "./data_types/hubspotSearchResult.js"; +import { HubSpotWebhookEventDataType } from "./data_types/hubspotWebhookEvent.js"; + +import { HubSpotContactCreatedWebhook } from "./events/hubspotContactCreatedWebhook.js"; +import { HubSpotDealCreatedWebhook } from "./events/hubspotDealCreatedWebhook.js"; +import { HubSpotDealStageChangedWebhook } from "./events/hubspotDealStageChangedWebhook.js"; +import { HubSpotFormSubmittedWebhook } from "./events/hubspotFormSubmittedWebhook.js"; + +import { HubSpotCreateContactFunction } from "./functions/hubspotCreateContactFunction.js"; +import { HubSpotUpdateContactFunction } from "./functions/hubspotUpdateContactFunction.js"; +import { HubSpotGetContactByEmailFunction } from "./functions/hubspotGetContactByEmailFunction.js"; +import { HubSpotCreateDealFunction } from "./functions/hubspotCreateDealFunction.js"; +import { HubSpotUpdateDealFunction } from "./functions/hubspotUpdateDealFunction.js"; +import { HubSpotCreateCompanyFunction } from "./functions/hubspotCreateCompanyFunction.js"; +import { HubSpotAddNoteFunction } from "./functions/hubspotAddNoteFunction.js"; +import { HubSpotAssociateFunction } from "./functions/hubspotAssociateFunction.js"; +import { HubSpotSearchObjectsFunction } from "./functions/hubspotSearchObjectsFunction.js"; + +const action = new Action( + process.env.ACTION_ID ?? "hubspot-action", + process.env.VERSION ?? "1.0.0", + process.env.AQUILA_URL ?? "127.0.0.1:8081", + "code0-tech", + "simple:hubspot", + "HubSpot CRM integration: react to contact/deal/company webhooks and create, update, search, associate, and annotate CRM records via the HubSpot CRM v3 API.", + [{ code: "en-US", content: "HubSpot" }], + [ + { + identifier: "access_token", + type: "TEXT", + name: [{ code: "en-US", content: "Access token" }], + description: [{ code: "en-US", content: "Private-app access token (Bearer) used to authenticate with the HubSpot CRM API." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "app_id", + type: "TEXT", + defaultValue: "", + name: [{ code: "en-US", content: "App id" }], + description: [{ code: "en-US", content: "Optional HubSpot app id, used when managing webhook subscriptions." }], + linkedDataTypes: ["TEXT"], + }, + { + identifier: "client_secret", + type: "TEXT", + defaultValue: "", + name: [{ code: "en-US", content: "Client secret" }], + description: [{ code: "en-US", content: "Optional HubSpot app client secret, used to validate the X-HubSpot-Signature on inbound webhooks." }], + linkedDataTypes: ["TEXT"], + }, + ] +); + +action.registerDataTypeClass(HubSpotContactDataType); +action.registerDataTypeClass(HubSpotDealDataType); +action.registerDataTypeClass(HubSpotCompanyDataType); +action.registerDataTypeClass(HubSpotNoteDataType); +action.registerDataTypeClass(HubSpotSearchResultDataType); +action.registerDataTypeClass(HubSpotWebhookEventDataType); + +action.registerEventClass(HubSpotContactCreatedWebhook); +action.registerEventClass(HubSpotDealCreatedWebhook); +action.registerEventClass(HubSpotDealStageChangedWebhook); +action.registerEventClass(HubSpotFormSubmittedWebhook); + +action.registerRuntimeFunction(HubSpotCreateContactFunction); +action.registerRuntimeFunction(HubSpotUpdateContactFunction); +action.registerRuntimeFunction(HubSpotGetContactByEmailFunction); +action.registerRuntimeFunction(HubSpotCreateDealFunction); +action.registerRuntimeFunction(HubSpotUpdateDealFunction); +action.registerRuntimeFunction(HubSpotCreateCompanyFunction); +action.registerRuntimeFunction(HubSpotAddNoteFunction); +action.registerRuntimeFunction(HubSpotAssociateFunction); +action.registerRuntimeFunction(HubSpotSearchObjectsFunction); + +action.on(CodeZeroEvent.connected, () => { + console.log("Connected to aquila"); +}); + +action.on(CodeZeroEvent.error, (error: Error) => { + console.error("Stream error:", error.message); + console.log("Attempting to reconnect in 5s..."); + setTimeout(() => { + action.connect(process.env.AUTH_TOKEN ?? "your_auth_token_here").catch((err: Error) => { + action.emit(CodeZeroEvent.error, err); + }); + }, 5000); +}); + +action.connect(process.env.AUTH_TOKEN ?? "your_auth_token_here").catch((err: Error) => { + action.emit(CodeZeroEvent.error, err); +}); + +action.on(CodeZeroEvent.moduleUpdated, (message: any) => { + console.dir(message, { depth: null }); + console.dir(action.configs.values(), { depth: null }); +}); + +export { action }; diff --git a/actions/hubspot-action/test/index.test.ts b/actions/hubspot-action/test/index.test.ts new file mode 100644 index 0000000..def1252 --- /dev/null +++ b/actions/hubspot-action/test/index.test.ts @@ -0,0 +1,71 @@ +import "reflect-metadata"; +import { describe, expect, it } from "vitest"; +import { HubSpotContactSchema } from "../src/data_types/hubspotContact.js"; +import { HubSpotSearchResultSchema } from "../src/data_types/hubspotSearchResult.js"; +import { HubSpotWebhookEventSchema } from "../src/data_types/hubspotWebhookEvent.js"; +import { parseProperties } from "../src/helpers.js"; + +describe("HubSpotContactSchema", () => { + it("parses a representative HubSpot object response", () => { + const response = { + id: "512", + properties: { email: "ada@example.com", firstname: "Ada", lastname: null }, + createdAt: "2026-07-23T12:00:00.000Z", + updatedAt: "2026-07-23T12:05:00.000Z", + archived: false, + }; + const parsed = HubSpotContactSchema.parse(response); + expect(parsed.id).toEqual("512"); + expect(parsed.properties.email).toEqual("ada@example.com"); + expect(parsed.properties.lastname).toBeNull(); + }); + + it("rejects an object without an id", () => { + expect(() => HubSpotContactSchema.parse({ properties: {} })).toThrow(); + }); +}); + +describe("HubSpotSearchResultSchema", () => { + it("parses a search result with a total and results array", () => { + const parsed = HubSpotSearchResultSchema.parse({ + total: 1, + results: [{ id: "1", properties: { name: "Acme" } }], + }); + expect(parsed.total).toEqual(1); + expect(parsed.results).toHaveLength(1); + }); +}); + +describe("HubSpotWebhookEventSchema", () => { + it("parses a property-change webhook event", () => { + const parsed = HubSpotWebhookEventSchema.parse({ + objectId: 123, + subscriptionType: "deal.propertyChange", + propertyName: "dealstage", + propertyValue: "closedwon", + }); + expect(parsed.propertyName).toEqual("dealstage"); + }); +}); + +describe("parseProperties", () => { + it("returns an empty object for empty input", () => { + expect(parseProperties(undefined)).toEqual({}); + expect(parseProperties("")).toEqual({}); + }); + + it("parses a JSON object and stringifies values", () => { + expect(parseProperties('{"firstname":"Ada","amount":42}')).toEqual({ + firstname: "Ada", + amount: "42", + }); + }); + + it("throws on invalid JSON", () => { + expect(() => parseProperties("not json")).toThrow(); + }); + + it("throws when JSON is not an object", () => { + expect(() => parseProperties("[1,2,3]")).toThrow(); + }); +}); diff --git a/actions/hubspot-action/tsconfig.json b/actions/hubspot-action/tsconfig.json new file mode 100644 index 0000000..65d780f --- /dev/null +++ b/actions/hubspot-action/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["node", "vite/client"] + }, + "include": [ + "src/**/*", + "test/**/*", + "vite.config.ts" + ] +} diff --git a/actions/hubspot-action/vite.config.ts b/actions/hubspot-action/vite.config.ts new file mode 100644 index 0000000..5790d9b --- /dev/null +++ b/actions/hubspot-action/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + target: "node18", + ssr: resolve(__dirname, 'src/index.ts'), + rollupOptions: { + external: (id) => + [ + 'fs', + 'path', + 'typescript', + '@code0-tech/hercules', + '@hubspot/api-client' + ].includes(id) || id.startsWith('node:') || id.startsWith('@hubspot/') + } + } +});