Skip to content

18029 actionhubspot cms and marketing api #18155

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

luancazarine
Copy link
Collaborator

@luancazarine luancazarine commented Aug 22, 2025

Resolves #18029

Summary by CodeRabbit

  • New Features

    • Added many HubSpot actions: create/update pages, landing pages, forms, marketing emails; clone site pages and emails; create contact workflows; update form fields.
    • New UI pickers for forms, pages, landing pages, templates, campaigns, and marketing emails.
  • Improvements

    • Global API rate limiting for more reliable HubSpot requests.
    • Added language options for page and email workflows.
    • Lead creation can associate a lead to a primary contact.
  • Chores

    • Widespread metadata version bumps and import reorders.

- Added new actions: Create Contact Workflow, Clone Email, Clone Site Page, Create Form, Update Landing Page, and Update Page.
- Enhanced existing actions with version increments for better compatibility.
- Introduced utility functions for improved data handling and object parsing.
- Updated dependencies in package.json to the latest versions.
- Refactored code for consistency and clarity across various actions and sources.
@luancazarine luancazarine linked an issue Aug 22, 2025 that may be closed by this pull request
Copy link

vercel bot commented Aug 22, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
pipedream-docs Ignored Ignored Aug 22, 2025 8:40pm
pipedream-docs-redirect-do-not-edit Ignored Ignored Aug 22, 2025 8:40pm

Copy link
Contributor

coderabbitai bot commented Aug 22, 2025

Walkthrough

Adds many HubSpot CMS and Marketing actions (forms, pages, emails, workflows), shared page props and utilities (cleanObject, LANGUAGE_OPTIONS), extensive hubspot.app extensions (new methods and propDefinitions) with Bottleneck rate limiting, plus many version bumps and minor import reorders; one functional tweak links created leads to contacts via associations.

Changes

Cohort / File(s) Summary
New CMS/Marketing actions
components/hubspot/actions/create-form/create-form.mjs, components/hubspot/actions/update-fields-on-the-form/update-fields-on-the-form.mjs, components/hubspot/actions/create-page/create-page.mjs, components/hubspot/actions/update-page/update-page.mjs, components/hubspot/actions/clone-site-page/clone-site-page.mjs, components/hubspot/actions/create-landing-page/create-landing-page.mjs, components/hubspot/actions/update-landing-page/update-landing-page.mjs, components/hubspot/actions/create-email/create-email.mjs, components/hubspot/actions/clone-email/clone-email.mjs, components/hubspot/actions/create-contact-workflow/create-contact-workflow.mjs
Adds create/clone/update actions for forms, pages, landing pages, emails, and a create-contact-workflow action; implements payload assembly, parseObject/cleanObject usage, API calls via hubspot.app, and summary exports.
App extensions & propDefinitions
components/hubspot/hubspot.app.mjs
Adds Bottleneck-based rate limiter (axiosRateLimiter), many new methods (create/update/list forms, pages, landing pages, emails, workflows, cloneSitePage, template/listing helpers), and new propDefinitions (formId, pageId, landingPageId, templatePath, landingFolderId, campaignId, emailId) with async options.
Shared props & utils
components/hubspot/actions/common/common-page-prop.mjs, components/hubspot/common/utils.mjs, components/hubspot/common/constants.mjs
Adds commonPageProp for page fields, introduces cleanObject utility, adds LANGUAGE_OPTIONS and API_PATH.AUTOMATIONV4, and adjusts exported constants.
Functional tweak: lead associations
components/hubspot/actions/create-lead/create-lead.mjs
When creating a lead, attaches associations payload linking the lead to contactId (associationCategory HUBSPOT_DEFINED, associationTypeId 578).
Version bumps & import reorders (actions & sources)
components/hubspot/actions/**, components/hubspot/sources/** (many files; see diff)
Numerous action/source version increments, minor import reorderings, and added ConfigurationError imports in some files; no other logic changes for those files.
Package metadata
components/hubspot/package.json
Bumps package version 1.5.0 → 1.6.0 and @pipedream/platform dependency ^3.0.0 → ^3.1.0.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Action as CMS/Marketing Action
  participant App as HubSpot App
  participant RL as Rate Limiter
  participant API as HubSpot API

  User->>Action: invoke action with props
  Action->>App: call create/update/clone method (payload, $)
  App->>RL: schedule/request via Bottleneck
  RL->>API: HTTP request
  API-->>RL: response
  RL-->>App: response
  App-->>Action: response data
  Action-->>User: $.export("$summary") + return
  note right of Action: parseObject/cleanObject applied where used
Loading
sequenceDiagram
  autonumber
  actor User
  participant Action as Create Contact Workflow
  participant App as HubSpot App
  participant API as HubSpot Automation v4

  User->>Action: configure workflow props
  Action->>App: createContactWorkflow(data)
  App->>API: POST /automation/v4/flows
  API-->>App: { id, ... }
  App-->>Action: response
  Action-->>User: "$summary: created workflow {id}"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Assessment against linked issues

Objective Addressed Explanation
Duplicate/create and edit forms [#18029]
Clone/create and edit pages (CMS) [#18029]
Clone/create emails (marketing) [#18029]
Clone/create workflows (automation) [#18029] Only create contact workflow implemented; no clone workflow action present.

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Add associations linking new lead to contact (components/hubspot/actions/create-lead/create-lead.mjs) Not part of CMS/Marketing API objectives; it's a CRM association behavior unrelated to requested CMS/Marketing features.
Introduce Bottleneck rate limiter and global request throttling (components/hubspot/hubspot.app.mjs) Global request rate-limiting is platform-level infrastructure change, not explicitly requested by the linked CMS/Marketing objectives.
Many version bumps and import reorderings (components/hubspot/actions/*, components/hubspot/sources/*) Metadata and formatting changes are peripheral to the CMS/Marketing feature requests.

Possibly related PRs

Suggested reviewers

  • michelle0927
  • jcortes
  • lcaresia

Poem

I thump and hop through code today,
Pages clone and forms at play,
Emails bloom and workflows start,
Bottleneck keeps the pace just smart.
A rabbit's cheer — shipped from the heart. 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ab6909b and 2a4398a.

📒 Files selected for processing (4)
  • components/hubspot/actions/clone-email/clone-email.mjs (1 hunks)
  • components/hubspot/actions/create-form/create-form.mjs (1 hunks)
  • components/hubspot/actions/create-page/create-page.mjs (1 hunks)
  • components/hubspot/actions/update-landing-page/update-landing-page.mjs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • components/hubspot/actions/update-landing-page/update-landing-page.mjs
  • components/hubspot/actions/create-form/create-form.mjs
  • components/hubspot/actions/create-page/create-page.mjs
  • components/hubspot/actions/clone-email/clone-email.mjs
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Lint Code Base
  • GitHub Check: Publish TypeScript components
  • GitHub Check: Verify TypeScript components
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 18029-actionhubspot-cms-and-marketing-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

…e-companies to 0.0.3 and new-email-event to 0.0.29 for improved compatibility.
- Added dependencies for components/prisma_management_api.
- Cleaned up specifier entries for components/mindee and components/zapr_link.
- Included transitive peer dependencies for better compatibility.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 23

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (31)
components/hubspot/sources/new-company-property-change/new-company-property-change.mjs (1)

49-79: Guard the date filter when after is undefined and coerce it to a string

On first runs, after will be undefined, and sending

{ propertyName: "hs_lastmodifieddate", operator: "GTE", value: undefined }

can lead to CRM Search errors. The Search API also expects filter values as strings. In components/hubspot/sources/new-company-property-change/new-company-property-change.mjs (lines 49–79), change getParams to:

   getParams(after) {
-    return {
-      object: "companies",
-      data: {
-        limit: DEFAULT_LIMIT,
-        properties: [ this.property ],
-        sorts: [{ propertyName: "hs_lastmodifieddate", direction: "DESCENDING" }],
-        filterGroups: [
-          {
-            filters: [
-              { propertyName: this.property, operator: "HAS_PROPERTY" },
-              {
-                propertyName: "hs_lastmodifieddate",
-                operator: "GTE",
-                value: after,
-              },
-            ],
-          },
-        ],
-      },
-    };
-  },
+    // Always require the property, but only filter by date if `after` is defined
+    const filters = [
+      { propertyName: this.property, operator: "HAS_PROPERTY" },
+    ];
+    if (after != null) {
+      filters.push({
+        propertyName: "hs_lastmodifieddate",
+        operator: "GTE",
+        value: String(after),    // ensure the API gets a string
+      });
+    }
+
+    return {
+      object: "companies",
+      data: {
+        limit: DEFAULT_LIMIT,
+        properties: [ this.property ],
+        sorts: [{ propertyName: "hs_lastmodifieddate", direction: "DESCENDING" }],
+        filterGroups: [{ filters }],
+      },
+    };
+  },
  • Moves the date filter behind an if (after != null) guard to avoid value: undefined
  • Uses String(after) to satisfy the API’s requirement for string‐typed filter values
components/hubspot/actions/list-marketing-emails/list-marketing-emails.mjs (1)

81-116: Critical bug: results shadowing and self-appending within iteration corrupts pagination and output.

  • You declare const results = [] (outer accumulator), then destructure { paging, results } from the API response, shadowing the accumulator.
  • Inside for (const item of results), you push into the same results you’re iterating, which can extend iteration unexpectedly and never populates the outer accumulator. The returned array will likely be empty and the loop may over-iterate.

Fix by using distinct names and pushing into the accumulator; also guard paging?.next?.after and only set params.after when present.

Apply this diff:

   async run({ $ }) {
-
-    const results = [];
-    let hasMore, count = 0;
+    const allResults = [];
+    let hasMore;
+    let count = 0;
@@
-    do {
-      const {
-        paging, results,
-      } = await this.hubspot.listMarketingEmails({
+    do {
+      const { paging, results: pageResults } = await this.hubspot.listMarketingEmails({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!pageResults?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of pageResults) {
+        allResults.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
-      hasMore = paging?.next.after;
-      params.after = paging?.next.after;
+      hasMore = Boolean(paging?.next?.after);
+      if (hasMore) {
+        params.after = paging.next.after;
+      }
     } while (hasMore && count < this.maxResults);
 
-    $.export("$summary", `Found ${results.length} email${results.length === 1
+    $.export("$summary", `Found ${allResults.length} email${allResults.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return allResults;
   },
components/hubspot/actions/list-blog-posts/list-blog-posts.mjs (2)

75-105: Fix results shadowing and mutation bug — function always returns [] and may duplicate items.

  • Outer accumulator results (Line 75) is shadowed by the destructured results from the API (Line 91).
  • Inside the loop you push into the API’s results while iterating it, not the accumulator (Lines 99-101). This both duplicates items and leaves the outer results empty, so the action returns [].

Apply this minimal fix:

-    const results = [];
+    const items = [];
     let hasMore, count = 0;
@@
-      const {
-        paging, results,
-      } = await this.hubspot.getBlogPosts({
+      const {
+        paging, results: apiResults,
+      } = await this.hubspot.getBlogPosts({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!apiResults?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of apiResults) {
+        items.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
-      hasMore = paging?.next.after;
-      params.after = paging?.next.after;
+      hasMore = Boolean(paging?.next?.after);
+      params.after = paging?.next?.after;
     } while (hasMore && count < this.maxResults);
@@
-    $.export("$summary", `Found ${results.length} page${results.length === 1
+    $.export("$summary", `Found ${items.length} blog post${items.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return items;

53-65: Action Required: Align sort.options with HubSpot’s CMS Blog Posts API property names

The sort parameter itself is correct—HubSpot expects a sort query parameter with the property name (optionally prefixed by - for descending) (developers.hubspot.com). However, the code’s list of allowed fields must exactly match the property keys exposed by the CMS Blog Posts API:

  • Replace createdBycreatedById
  • Replace updatedByupdatedById

Additionally, if you wish to support sorting by publish date (a common use case), consider adding publishDate to the options.

Please update the snippet in components/hubspot/actions/list-blog-posts/list-blog-posts.mjs accordingly:

     sort: {
       type: "string",
       label: "Sort",
       description: "Sort the results by the specified field",
-      options: [
-        "name",
-        "createdAt",
-        "updatedAt",
-        "createdBy",
-        "updatedBy",
-      ],
+      options: [
+        "name",
+        "createdAt",
+        "updatedAt",
+        "createdById",
+        "updatedById",
+        // Optional: sort by publish date
+        "publishDate",
+      ],
       optional: true,
     },
components/hubspot/actions/list-campaigns/list-campaigns.mjs (1)

34-66: Fix variable shadowing and self-appending loop that corrupts results

  • The outer results array is shadowed by the destructured { results } from the API response.
  • Inside the loop, you push into the same array you’re iterating (results.push(item)), duplicating entries and risking pathological iteration. The outer results stays empty, causing an empty return and summary.

Refactor to separate page results from the accumulator and handle paging defensively.

Apply this diff:

-    const results = [];
-    let hasMore, count = 0;
+    const campaigns = [];
+    let after;
+    let count = 0;
@@
-    const params = {
-      sort: this.sort,
-    };
+    const params = { sort: this.sort };
@@
-      const {
-        paging, results,
-      } = await this.hubspot.listCampaigns({
+      const {
+        paging, results: page,
+      } = await this.hubspot.listCampaigns({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!page?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of page) {
+        campaigns.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
-      hasMore = paging?.next.after;
-      params.after = paging?.next.after;
-    } while (hasMore && count < this.maxResults);
+      after = paging?.next?.after;
+      if (after) params.after = after;
+      else delete params.after;
+    } while (after && count < this.maxResults);
@@
-    $.export("$summary", `Found ${results.length} campaign${results.length === 1
+    $.export("$summary", `Found ${campaigns.length} campaign${campaigns.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return campaigns;
components/hubspot/actions/get-file-public-url/get-file-public-url.mjs (1)

31-35: Guard against undefined file before dereferencing id

If find returns undefined, accessing file.id throws before your explicit not-found check. Validate file first.

Apply this diff:

-    const file = files.find(({ url }) => url === fileUrl );
-    const fileId = file.id;
-    if (!fileId) {
-      throw new Error(`File not found at ${fileUrl}`);
-    }
+    const file = files.find(({ url }) => url === fileUrl);
+    if (!file) {
+      throw new Error(`File not found at ${fileUrl}`);
+    }
+    const fileId = file.id;
components/hubspot/actions/batch-create-or-update-contact/batch-create-or-update-contact.mjs (3)

28-44: Potential crash when no results are returned and undefined emails are included

Two issues:

  • Line 28 collects email without filtering falsy values, so undefined can be sent to the IN filter.
  • Line 42 sets updateEmails = results?.map(...), which becomes undefined when results is falsy, leading to updateEmails.includes(...) throwing at Line 43.

Patch below makes this path safe with minimal change.

-      const emails = contacts.map(({ email }) => email);
+      const emails = contacts.map(({ email }) => email).filter(Boolean);
@@
-      const updateEmails = results?.map(({ properties }) => properties.email);
-      const insertProperties = contacts.filter(({ email }) => !updateEmails.includes(email))
+      const updateEmails = (results ?? []).map(({ properties }) => properties.email).filter(Boolean);
+      const insertProperties = contacts.filter(({ email }) => !updateEmails.includes(email))
         .map((properties) => ({
           properties,
         }));

27-58: Partition create/update correctly, dedupe updates, and validate inputs

Current flow risks:

  • Contacts with an id can be pushed into both create and update arrays (due to only filtering by email for creates) causing duplicate/conflicting requests.
  • Update set may contain duplicates when mixing “found by email” and “provided id” paths.
  • Records without id and without email are silently processed, but HubSpot can’t create a contact without at least an email.

Refactor:

  • Validate each contact has either id (update) or email (create/update).
  • Partition by presence of id.
  • Search only the “no id but has email” cohort, compute inserts (non-existing emails) and updates (existing emails).
  • Merge updates from “by email” and “by id”, deduping by id (id-driven updates take precedence).

Apply this diff:

@@
-    async searchExistingContactProperties(contacts, $) {
-      const emails = contacts.map(({ email }) => email).filter(Boolean);
-      const { results } = await this.hubspot.searchCRM({
-        $,
-        object: "contact",
-        data: {
-          filters: [
-            {
-              propertyName: "email",
-              operator: "IN",
-              values: emails,
-            },
-          ],
-        },
-      });
-      const updateEmails = (results ?? []).map(({ properties }) => properties.email).filter(Boolean);
-      const insertProperties = contacts.filter(({ email }) => !updateEmails.includes(email))
-        .map((properties) => ({
-          properties,
-        }));
-      const updateProperties = [];
-      for (const contact of results) {
-        updateProperties.push({
-          id: contact.id,
-          properties: contacts.find(({ email }) => contact.properties.email === email),
-        });
-      }
-      return {
-        insertProperties,
-        updateProperties,
-      };
-    },
+    async searchExistingContactProperties(contacts, $) {
+      // Consider only contacts that do NOT have an id and DO have an email
+      const candidates = contacts.filter((c) => !Object.prototype.hasOwnProperty.call(c, "id") && c.email);
+      const emails = [...new Set(candidates.map(({ email }) => email).filter(Boolean))];
+      if (!emails.length) {
+        return { insertProperties: [], updateProperties: [] };
+      }
+      const { results = [] } = await this.hubspot.searchCRM({
+        $,
+        object: "contact",
+        data: {
+          filters: [
+            {
+              propertyName: "email",
+              operator: "IN",
+              values: emails,
+            },
+          ],
+        },
+      });
+      const existingEmails = new Set(results.map(({ properties }) => properties.email).filter(Boolean));
+      const insertProperties = candidates
+        .filter(({ email }) => !existingEmails.has(email))
+        .map((properties) => ({ properties }));
+      const byEmail = new Map(candidates.map((c) => [c.email, c]));
+      const updateProperties = results.map(({ id, properties }) => ({
+        id,
+        properties: byEmail.get(properties.email),
+      }));
+      return { insertProperties, updateProperties };
+    },
@@
-    const {
-      insertProperties, updateProperties,
-    } = await this.searchExistingContactProperties(contacts, $);
+    // Validate minimal required identifiers
+    const invalid = contacts.filter((c) =>
+      !Object.prototype.hasOwnProperty.call(c, "id") && !c.email);
+    if (invalid.length) {
+      throw new Error("Each contact must include either an 'id' (for update) or an 'email' (for create/update).");
+    }
+    const { insertProperties, updateProperties } =
+      await this.searchExistingContactProperties(contacts, $);
@@
-    const updatePropertiesWithId = contacts.filter((contact) => (Object.prototype.hasOwnProperty.call(contact, "id")))
-      .map(({
-        id, ...properties
-      }) => ({
-        id: id,
-        properties,
-      }));
-
-    if (updatePropertiesWithId?.length) {
-      updateProperties.push(...updatePropertiesWithId);
-    }
+    const updatesById = contacts
+      .filter((c) => Object.prototype.hasOwnProperty.call(c, "id"))
+      .map(({ id, ...properties }) => ({ id, properties }));
+    // Merge and dedupe updates by id (id-driven updates take precedence)
+    const mergedUpdateMap = new Map(updateProperties.map((u) => [String(u.id), u]));
+    for (const u of updatesById) mergedUpdateMap.set(String(u.id), u);
+    const mergedUpdates = Array.from(mergedUpdateMap.values());
@@
-    response.created = await this.hubspot.batchCreateContacts({
-      $,
-      data: {
-        inputs: insertProperties,
-      },
-    });
-    response.updated = await this.hubspot.batchUpdateContacts({
-      $,
-      data: {
-        inputs: updateProperties,
-      },
-    });
+    response.created = await this.hubspot.batchCreateContacts({
+      $,
+      data: { inputs: insertProperties },
+    });
+    response.updated = await this.hubspot.batchUpdateContacts({
+      $,
+      data: { inputs: mergedUpdates },
+    });
@@
-    $.export("$summary", `Successfully created ${insertProperties.length} and updated ${updateProperties.length} contacts`);
+    $.export("$summary", `Successfully created ${insertProperties.length} and updated ${mergedUpdates.length} contacts`);

Also applies to: 61-77, 79-96


79-91: Implement chunking for HubSpot batch endpoints

The batchCreateContacts and batchUpdateContacts methods in hubspot.app.mjs do not enforce the 100-item limit; they simply pass through your opts as-is. To avoid request failures when insertProperties or updateProperties exceeds HubSpot’s 100-item cap, add client-side chunking in this action:

• In components/hubspot/actions/batch-create-or-update-contact/batch-create-or-update-contact.mjs (around lines 79–91), wrap each call in a loop that:

  1. Splits the full inputs array into subarrays of at most 100 entries.
  2. Calls this.hubspot.batchCreateContacts (or batchUpdateContacts) for each chunk.
  3. Aggregates the individual responses into a combined response.created / response.updated.

• You can implement a small helper in this module, for example:

const CHUNK_SIZE = 100;
function chunkArray(arr, size) {
  const chunks = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}

// then in your action:
response.created = [];
for (const chunk of chunkArray(insertProperties, CHUNK_SIZE)) {
  const res = await this.hubspot.batchCreateContacts({ $, data: { inputs: chunk } });
  response.created.push(...res.results);
}

This will ensure you never send more than 100 contacts per request and handle arbitrary-sized payloads gracefully.

components/hubspot/sources/new-engagement/new-engagement.mjs (1)

19-19: Typo in user-facing description (“engagment”)

Fix the typo to avoid user confusion in the UI.

-      description: "Filter results by the type of engagment",
+      description: "Filter results by the type of engagement",
components/hubspot/actions/list-marketing-events/list-marketing-events.mjs (1)

26-45: Critical: variable shadowing and self-appending during iteration corrupt results

Inside the loop:

  • You destructure results from the API response, which shadows the outer results accumulator.
  • You then for ... of results and push to results inside the same loop. This mutates the array being iterated and, combined with shadowing, results in incorrect accumulation (and can lead to unexpected iteration growth). The outer accumulator remains empty, so the function returns [] and the summary reports 0.

Fix by separating page results from the accumulator and pushing into the accumulator only.

-    const results = [];
+    const events = [];
@@
-      const {
-        paging, results,
-      } = await this.hubspot.listMarketingEvents({
+      const {
+        paging, results: pageResults,
+      } = await this.hubspot.listMarketingEvents({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!pageResults?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of pageResults) {
+        events.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
       hasMore = paging?.next.after;
       params.after = paging?.next.after;
     } while (hasMore && count < this.maxResults);
@@
-    $.export("$summary", `Found ${results.length} event${results.length === 1
+    $.export("$summary", `Found ${events.length} event${events.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return events;

Also applies to: 20-24, 47-51

components/hubspot/actions/get-associated-meetings/get-associated-meetings.mjs (1)

192-202: Custom timeframe filter operators are inverted

For a custom range, start should be GTE startDate and end should be LTE endDate. Current logic uses the opposite, which will return incorrect/empty results.

       case "custom":
         return {
           hs_meeting_start_time: {
-            operator: "LTE",
-            value: startDate,
+            operator: "GTE",
+            value: startDate,
           },
           hs_meeting_end_time: {
-            operator: "GTE",
-            value: endDate,
+            operator: "LTE",
+            value: endDate,
           },
         };
components/hubspot/sources/new-task/new-task.mjs (2)

36-41: Fix: spreading an undefined value throws at runtime.

If listSchemas() returns an object without a results array, custom becomes undefined and ...customObjects will throw. Default to an empty array.

-      const { results: custom } = await this.hubspot.listSchemas();
-      const customObjects = custom?.map(({ fullyQualifiedName }) => fullyQualifiedName);
+      const { results: custom = [] } = await this.hubspot.listSchemas();
+      const customObjects = custom.map(({ fullyQualifiedName }) => fullyQualifiedName);

52-53: Use correct binding context for hubspot.listTasks

Verified that listTasks in hubspot.app.mjs invokes this.makeRequest (line 1265), so binding it to the integration component (this) will break its context. It must be bound to the HubSpot client instance instead.

• File: components/hubspot/sources/new-task/new-task.mjs (around lines 52–53)
• Replace with:

- const tasks = await this.getPaginatedItems(this.hubspot.listTasks.bind(this), params);
+ const tasks = await this.getPaginatedItems(this.hubspot.listTasks.bind(this.hubspot), params);
components/hubspot/sources/new-deal-property-change/new-deal-property-change.mjs (1)

27-33: Fix: robust timestamp handling for property history.

HubSpot property history timestamps are often epoch milliseconds (number). Date.parse(number) returns NaN. Handle both number and string to prevent NaN timestamps propagating to dedupe logic and meta.

-    getTs(deal) {
-      const history = deal.propertiesWithHistory[this.property];
-      if (!history || !(history.length > 0)) {
-        return;
-      }
-      return Date.parse(history[0].timestamp);
-    },
+    getTs(deal) {
+      const history = deal.propertiesWithHistory?.[this.property];
+      if (!history?.length) return;
+      const t = history[0]?.timestamp;
+      const ts = typeof t === "number" ? t : Date.parse(t);
+      return Number.isFinite(ts) ? ts : undefined;
+    },
components/hubspot/sources/new-note/new-note.mjs (2)

36-41: Fix: spreading an undefined value throws at runtime.

Mirror the task source fix—default results to [] to keep ...customObjects safe.

-      const { results: custom } = await this.hubspot.listSchemas();
-      const customObjects = custom?.map(({ fullyQualifiedName }) => fullyQualifiedName);
+      const { results: custom = [] } = await this.hubspot.listSchemas();
+      const customObjects = custom.map(({ fullyQualifiedName }) => fullyQualifiedName);

52-53: Bind listNotes to the HubSpot client context

The listNotes implementation invokes this.makeRequest, so binding it to the component’s this will break its internal this reference. Update the call in components/hubspot/sources/new-note/new-note.mjs accordingly:

• File: components/hubspot/sources/new-note/new-note.mjs
• Lines: 52–53

-      const notes = await this.getPaginatedItems(this.hubspot.listNotes.bind(this), params);
+      const notes = await this.getPaginatedItems(this.hubspot.listNotes.bind(this.hubspot), params);
       await this.processEvents(notes, after);
components/hubspot/actions/create-meeting/create-meeting.mjs (2)

83-85: Tighten validation when association fields are partially filled

If either association field is provided, require both and a target type. This prevents confusing 400s from HubSpot.

Apply this diff:

-    if ((toObjectId && !associationType) || (!toObjectId && associationType)) {
-      throw new ConfigurationError("Both `toObjectId` and `associationType` must be entered");
-    }
+    if ((toObjectId && !associationType) || (!toObjectId && associationType)) {
+      throw new ConfigurationError("Both `toObjectId` and `associationType` must be entered");
+    }
+    if ((toObjectId || associationType) && !toObjectType) {
+      throw new ConfigurationError("`toObjectType` is required when associating a meeting.");
+    }

100-105: Derive associationCategory dynamically instead of hard-coding HUBSPOT_DEFINED

The current implementation always forces associationCategory: ASSOCIATION_CATEGORY.HUBSPOT_DEFINED, which will break for USER_DEFINED or INTEGRATOR_DEFINED types.

Key locations to update:

  • components/hubspot/hubspot.app.mjs (around line 369)
    The associationType propDefinition returns only an integer (typeId) as the value—and no category field—so downstream code can’t know the correct category.
  • components/hubspot/actions/create-meeting/create-meeting.mjs (lines 100–105)
    The types array is still hard-coded to HUBSPOT_DEFINED.

You should refactor one of two ways:

  1. Extend the prop to return both id and category:
    In common.props.hubspot.associationType.options, map each type to

    return associationTypes.map((t) => ({
      label: t.label,
    - value: t.typeId,
    + value: { id: t.typeId, category: t.category },
    }));

    Then update your create-meeting action to unpack that object.

  2. Fetch the category in the action itself:

    const { results } = await this.hubspot.getAssociationTypes({ fromObjectType, toObjectType });
    const assoc = results.find(({ typeId }) => typeId === associationType);
    // …
    types: [
      {
        associationTypeId: assoc.typeId,
        associationCategory: assoc.category,
      },
    ],

Finally, apply this diff in create-meeting.mjs to replace the hard-coded snippet:

-          types: [
-            {
-              associationTypeId: associationType,
-              associationCategory: ASSOCIATION_CATEGORY.HUBSPOT_DEFINED,
-            },
-          ],
+          types: [
+            (() => {
+              // derive id/category from the prop or by calling getAssociationTypes
+              const assoc = /* … */;
+              return {
+                associationTypeId: assoc.id,
+                associationCategory: assoc.category,
+              };
+            })(),
+          ],

This change is critical: forcing HUBSPOT_DEFINED for user- or integrator-defined types will cause API errors. Please address before merging.

components/hubspot/actions/create-lead/create-lead.mjs (1)

35-55: Hardcoded associationTypeId and unconditional association can cause failures or brittleness.

  • The magic number 578 can change or vary; hardcoding is brittle.
  • If contactId is absent/invalid, the API call will fail.
  • Overwrites any associations provided in opts.data instead of merging.

Refactor to (a) merge with any provided associations, (b) guard on contactId, (c) cast id to string, and (d) use a named constant.

Apply this diff:

     createObject(opts) {
-      return this.hubspot.createObject({
-        ...opts,
-        data: {
-          ...opts?.data,
-          associations: [
-            {
-              types: [
-                {
-                  associationCategory: ASSOCIATION_CATEGORY.HUBSPOT_DEFINED,
-                  associationTypeId: 578, // ID for "Lead with Primary Contact"
-                },
-              ],
-              to: {
-                id: this.contactId,
-              },
-            },
-          ],
-        },
-      });
+      const incomingAssociations = opts?.data?.associations ?? [];
+      const associations = [...incomingAssociations];
+
+      if (this.contactId) {
+        associations.push({
+          types: [
+            {
+              associationCategory: ASSOCIATION_CATEGORY.HUBSPOT_DEFINED,
+              associationTypeId: ASSOCIATION_TYPE_ID.LEAD_WITH_PRIMARY_CONTACT,
+            },
+          ],
+          to: {
+            id: String(this.contactId),
+          },
+        });
+      }
+
+      return this.hubspot.createObject({
+        ...opts,
+        data: {
+          ...opts?.data,
+          associations,
+        },
+      });
     },

Add this constant to your HubSpot constants (see next snippet), or alternatively resolve the association type dynamically at runtime via the Associations API.

components/hubspot/actions/list-pages/list-pages.mjs (1)

75-105: Critical: variable shadowing corrupts results and can trigger an unbounded loop.

  • Outer results array is shadowed by the destructured results from the API response.
  • You then push into the array you’re iterating (inner results), potentially extending iteration indefinitely until maxResults, and you never populate the outer results, so you return [].

Fix by renaming one side and pushing into the outer accumulator. Also ensure hasMore is boolean.

Apply this diff:

-    const results = [];
+    const pages = [];
@@
-      const {
-        paging, results,
-      } = await this.hubspot.listPages({
+      const {
+        paging, results: pageResults,
+      } = await this.hubspot.listPages({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!pageResults?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of pageResults) {
+        pages.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
-      hasMore = paging?.next.after;
+      hasMore = Boolean(paging?.next?.after);
       params.after = paging?.next.after;
     } while (hasMore && count < this.maxResults);
@@
-    $.export("$summary", `Found ${results.length} page${results.length === 1
+    $.export("$summary", `Found ${pages.length} page${pages.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return pages;
components/hubspot/actions/list-forms/list-forms.mjs (2)

26-52: Fix variable shadowing and self-appending bug; results are never accumulated and loop can degenerate.

  • const results = [] (accumulator) is shadowed by the destructured { results } from the API response.
  • Inside the for...of, you push to the response array you’re iterating instead of the accumulator, which can cause growth during iteration and incorrect behavior. The outer accumulator remains empty.

Apply this diff to use distinct names and push into the correct accumulator:

-    const results = [];
-    let hasMore, count = 0;
+    const items = [];
+    let after;
+    let count = 0;
@@
-    const params = {
-      archived: this.archived,
-    };
+    const params = {
+      archived: this.archived,
+    };
@@
-      const {
-        paging, results,
-      } = await this.hubspot.listMarketingForms({
+      const {
+        paging, results: pageResults,
+      } = await this.hubspot.listMarketingForms({
         $,
         params,
       });
-      if (!results?.length) {
+      if (!pageResults?.length) {
         break;
       }
-      for (const item of results) {
-        results.push(item);
+      for (const item of pageResults) {
+        items.push(item);
         count++;
         if (count >= this.maxResults) {
           break;
         }
       }
-      hasMore = paging?.next.after;
-      params.after = paging?.next.after;
-    } while (hasMore && count < this.maxResults);
+      after = paging?.next?.after;
+      params.after = after;
+    } while (after && count < this.maxResults);

54-58: Return and summary currently reference the wrong variable (always 0).

Update to use the accumulator from the fix above:

-    $.export("$summary", `Found ${results.length} form${results.length === 1
+    $.export("$summary", `Found ${items.length} form${items.length === 1
       ? ""
       : "s"}`);
-    return results;
+    return items;
components/hubspot/actions/create-associations/create-associations.mjs (1)

69-80: Guard against missing association and use the argument value (not this.*).

results.find(...) may return undefined, causing association.category to throw. Also, prefer the passed associationType arg over this.associationType inside the method.

   async getAssociationCategory({
     $, fromObjectType, toObjectType, associationType,
   }) {
     const { results } = await this.hubspot.getAssociationTypes({
       $,
       fromObjectType,
       toObjectType,
       associationType,
     });
-    const association = results.find(({ typeId }) => typeId === this.associationType);
-    return association.category;
+    const association = results?.find(({ typeId }) => typeId === associationType);
+    if (!association) {
+      throw new ConfigurationError("Association type not found for the selected object types.");
+    }
+    return association.category;
   },
components/hubspot/actions/batch-create-companies/batch-create-companies.mjs (1)

42-45: Make error parsing robust; current logic can throw and hide the real error.

JSON.parse(JSON.parse(error.message).message) is brittle. Prefer safe parsing and fallbacks to error.response.data.

-    } catch (error) {
-      const message = JSON.parse((JSON.parse(error.message).message).split(/:(.+)/)[1])[0].message;
-      throw new ConfigurationError(message.split(/:(.+)/)[0]);
-    }
+    } catch (error) {
+      const fallback = error?.message || "Failed to create companies.";
+      let details = fallback;
+      try {
+        // Try message -> nested message -> array of errors
+        const parsed1 = typeof error?.message === "string" ? JSON.parse(error.message) : null;
+        const parsed2 = parsed1 && typeof parsed1.message === "string" ? JSON.parse(parsed1.message) : null;
+        details = Array.isArray(parsed2)
+          ? parsed2.map((e) => e?.message).filter(Boolean).join("; ")
+          : parsed1?.message || fallback;
+      } catch {
+        const data = error?.response?.data;
+        details = data?.errors?.map((e) => e?.message).filter(Boolean).join("; ")
+          || data?.message
+          || fallback;
+      }
+      throw new ConfigurationError(details);
+    }
components/hubspot/actions/batch-upsert-companies/batch-upsert-companies.mjs (2)

46-48: Brittle error parsing can throw secondary JSON.parse errors; prefer robust extraction from error.response.data.

Current approach double-parses strings and splits on colons, which breaks on non-JSON messages and hides original errors. Extract directly from Axios-style response where available, with safe fallbacks.

Apply this diff:

-      const message = JSON.parse((JSON.parse(error.message).message).split(/:(.+)/)[1])[0].message;
-      throw new ConfigurationError(message.split(/:(.+)/)[0]);
+      const data = error?.response?.data;
+      const details = Array.isArray(data?.errors)
+        ? data.errors.map((e) => e?.message).filter(Boolean)
+        : [];
+      const base = [data?.message, ...details].filter(Boolean).join(" | ");
+      const msg = base || error?.message || "Unknown HubSpot error";
+      throw new ConfigurationError(msg);

1-50: Extract and Centralize HubSpot Error Parsing Logic

I ran the provided search and found the same brittle error-parsing pattern duplicated in three action files. To ensure consistency and simplify future updates, extract this logic into a shared helper (e.g., in common/utils.mjs or on the HubSpot app) and replace each inline parsing with a call to that helper.

Files and locations to refactor:

  • components/hubspot/actions/batch-upsert-companies/batch-upsert-companies.mjs (lines 46–47)
  • components/hubspot/actions/batch-update-companies/batch-update-companies.mjs (lines 43–44)
  • components/hubspot/actions/batch-create-companies/batch-create-companies.mjs (lines 43–44)

Suggested steps:

  • Create a shared function, e.g.:
    // common/utils.mjs
    export function parseHubspotError(error) {
      // Extract and return the first error message
      const raw = JSON.parse(error.message);
      const detail = JSON.parse(raw.message).split(/:(.+)/)[1];
      return JSON.parse(detail)[0].message.split(/:(.+)/)[0];
    }
  • In each action’s catch block, replace the inline logic:
    - const message = JSON.parse((JSON.parse(error.message).message).split(/:(.+)/)[1])[0].message;
    - throw new ConfigurationError(message.split(/:(.+)/)[0]);
    + throw new ConfigurationError(parseHubspotError(error));

This refactor will reduce duplication and improve maintainability.

components/hubspot/actions/batch-update-companies/batch-update-companies.mjs (1)

43-45: Harden error extraction — avoid double JSON.parse and regex splitting.

Mirror the safer approach suggested in batch-upsert to prevent masking the original error.

Apply this diff:

-      const message = JSON.parse((JSON.parse(error.message).message).split(/:(.+)/)[1])[0].message;
-      throw new ConfigurationError(message.split(/:(.+)/)[0]);
+      const data = error?.response?.data;
+      const details = Array.isArray(data?.errors)
+        ? data.errors.map((e) => e?.message).filter(Boolean)
+        : [];
+      const base = [data?.message, ...details].filter(Boolean).join(" | ");
+      const msg = base || error?.message || "Unknown HubSpot error";
+      throw new ConfigurationError(msg);
components/hubspot/actions/search-crm/search-crm.mjs (3)

83-89: Guard against missing property metadata when building Search Property options

If a name listed in schema.searchableProperties isn’t present in schema.properties, propData will be undefined, causing a crash when accessing .label. Add a fallback and filter out unresolved props.

-      const searchableProperties = schema.searchableProperties?.map((prop) => {
-        const propData = properties.find(({ name }) => name === prop);
-        return {
-          label: propData.label,
-          value: propData.name,
-        };
-      });
+      const searchableProperties = schema.searchableProperties
+        ?.map((prop) => {
+          const propData = properties.find(({ name }) => name === prop);
+          return propData
+            ? { label: propData.label, value: propData.name }
+            : { label: prop, value: prop };
+        })
+        .filter(Boolean);

252-257: Validate presence and validity of searchProperty before calling the API

If searchProperty is undefined, the current message is misleading. Also, guard against schemas that don’t expose searchableProperties.

-    if (!schema.searchableProperties.includes(searchProperty)) {
+    if (!searchProperty) {
+      throw new ConfigurationError("A Search Property is required for this action.");
+    }
+    if (!Array.isArray(schema.searchableProperties) || !schema.searchableProperties.includes(searchProperty)) {
       throw new ConfigurationError(
         `Property \`${searchProperty}\` is not a searchable property of object type \`${objectType}\`. ` +
         `\n\nAvailable searchable properties are: \`${schema.searchableProperties.join("`, `")}\``,
       );
     }

289-293: Make partial-match filtering resilient to non-string property values

toLowerCase() will throw for non-strings (numbers, booleans). Normalize both sides to string before comparison.

-    if (!exactMatch) {
-      results = results.filter((result) =>
-        result.properties[searchProperty]
-        && result.properties[searchProperty].toLowerCase().includes(searchValue.toLowerCase()));
-    }
+    if (!exactMatch) {
+      const needle = String(searchValue ?? "").toLowerCase();
+      results = results.filter((result) => {
+        const raw = result?.properties?.[searchProperty];
+        if (raw === undefined || raw === null) return false;
+        const hay = String(raw).toLowerCase();
+        return hay.includes(needle);
+      });
+    }

luancazarine and others added 5 commits August 22, 2025 17:34
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
…page.mjs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Collaborator

@jcortes jcortes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @luancazarine lgtm! Ready for QA!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[ACTION]Hubspot CMS and Marketing API
2 participants