UNPKG

@hoover-institution/hubspot-lib

Version:

A toolkit for deep integration with HubSpot's Marketing Events API with a plugin-based architecture.

995 lines (901 loc) 30.3 kB
// @ts-nocheck import { run as runHooks } from "../core/dispatcher.js"; import { EVENTS } from "../core/events.js"; import axios from "axios"; import { ContactManager } from "./ContactManager.js"; import { resolveHooks } from "../core/resolveHooks.js"; import { loadPlugins } from "../core/pluginLoader.js"; import { fullPluginMap } from "../core/loadPlugins.js"; /** * Enum of valid subscriber states ("REGISTERED", "ATTENDED", etc.) */ /** * Enum for subscriber state values. * @readonly * @enum {number} * @property {number} REGISTERED * @property {number} ATTENDED * @property {number} CANCELED */ export const SUBSCRIBER_STATE = { REGISTERED: 2, ATTENDED: 1, CANCELED: 0, }; /** * Custom library for managing marketing events in HubSpot. * Provides methods to create, retrieve, and delete marketing events via the HubSpot API. * Follows REST conventions: PUT is idempotent upsert with status reporting. * All methods return both HubSpot data and plugin execution results. * * Example usage: * const me = new MarketingEvent(externalAccountId, { plugins: ["PLUGIN_NAME"] }); * const result = await me.createEvent({ ... }); * console.log(result.hubspot); // HubSpot response * console.log(result.plugins); // Plugin execution results */ /** * @typedef {'REGISTERED' | 'ATTENDED' | 'CANCELED'} SubscriberStateName */ /** * @typedef {Object} PluginResult * @property {number} pluginId * @property {string} pluginName * @property {boolean} success * @property {any} [result] * @property {string} [error] */ /** * @typedef {Object} HubSpotEvent * @property {string} eventName * @property {string} eventType * @property {string} startDateTime * @property {string|null} endDateTime * @property {string} eventOrganizer * @property {string|null} eventDescription * @property {string|null} eventUrl * @property {boolean} eventCancelled * @property {boolean} eventCompleted * @property {Array} customProperties * @property {string} objectId * @property {string} id * @property {string} createdAt * @property {string} updatedAt * @property {string|number} externalEventId * @property {string} externalAccountId * @property {string} status */ /** * @typedef {Object} EventResult * @property {HubSpotEvent} hubspot The HubSpot event data * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} EventsResult * @property {HubSpotEvent[]} hubspot Array of HubSpot event data * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} DeleteResult * @property {{ success: boolean }} hubspot Deletion confirmation * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} RegisterEmailResult * @property {boolean} success Indicates if the registration or cancellation was successful. * @property {string} email The email address of the contact. * @property {number} subscriberState The subscriber state after the operation (e.g., REGISTERED, CANCELED, ATTENDED). * @property {string} contactId The HubSpot contact ID associated with the email. */ /** * @typedef {Object} RegisterEmailResultWithPlugins * @property {RegisterEmailResult} hubspot The registration result data * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} ContactsResult * @property {HubSpotContact[]} hubspot Array of HubSpot contact data * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} ContactListResult * @property {HubSpotContactList} hubspot The HubSpot contact list data * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} ContactStateResult * @property {SubscriberStateName|null} hubspot The contact's subscriber state * @property {PluginResult[]} plugins Array of plugin execution results */ /** * @typedef {Object} HubSpotContact * @property {string} id * @property {Object} properties * @property {string} properties.createdate * @property {string} properties.email * @property {string} properties.firstname * @property {string} properties.hs_object_id * @property {string} properties.lastmodifieddate * @property {string} properties.lastname * @property {string} createdAt * @property {string} updatedAt * @property {boolean} archived */ /** * @typedef {Object} HubSpotContactList * @property {string} listId The unique identifier for the contact list. * @property {number} listVersion The version number of the list. * @property {string} createdAt ISO timestamp when the list was created. * @property {string} updatedAt ISO timestamp when the list was last updated. * @property {string} filtersUpdatedAt ISO timestamp when the list filters were last updated. * @property {string} processingStatus The processing status of the list (e.g., "COMPLETE"). * @property {string} createdById The user ID who created the list. * @property {string} updatedById The user ID who last updated the list. * @property {string} processingType The processing type of the list (e.g., "MANUAL"). * @property {string} objectTypeId The object type ID (e.g., "0-1" for contacts). * @property {string} name The name of the list. * @property {Object} listPermissions Permissions for the list. * @property {Array} listPermissions.teamsWithEditAccess Teams with edit access. * @property {Array} listPermissions.usersWithEditAccess Users with edit access. * @property {Object} membershipSettings Membership settings for the list. * @property {string|null} membershipSettings.membershipTeamId Team ID for membership, if any. * @property {boolean|null} membershipSettings.includeUnassigned Whether to include unassigned contacts. */ // /** // * Enum for subscriber state values. // * @readonly // * @enum {number} // * @property {number} REGISTERED // * @property {number} ATTENDED // * @property {number} CANCELED // */ // export const SUBSCRIBER_STATE = { // REGISTERED: 2, // ATTENDED: 1, // CANCELED: 0, // }; export class MarketingEvent { /** @private */ #externalEventId = null; /** @private */ #externalAccountId = null; /** @private */ #client; /** @private */ #pluginInit; /** * Creates a new MarketingEvent instance. * @param {string} externalAccountId The external account ID for HubSpot context. * @param {Object} [options] * @param {string[]} [options.plugins] Array of plugin names to load. */ constructor(externalAccountId, options = {}) { if (!externalAccountId) { throw new Error("Constructor requires 'externalAccountId'"); } // Normalize and dedupe plugin names let plugins = Array.isArray(options.plugins) ? [...new Set(options.plugins.filter(Boolean))] : []; if (plugins.includes("ALL")) { plugins = Object.keys(fullPluginMap).filter((name) => name !== "ALL"); } // Set up hook bitmask from plugin names this.hookFlags = resolveHooks(plugins); // Trigger plugin loading (native, external, or inline) this.#pluginInit = loadPlugins(plugins); // Store account and create internal API client this.#externalAccountId = externalAccountId; this.#client = axios.create({ baseURL: MarketingEvent.eventBaseUrl, headers: { Authorization: `Bearer ${process.env.HUBSPOT_KEY}`, "Content-Type": "application/json", }, timeout: 5000, }); } /** * HubSpot Marketing Events API base URL. * @returns {string} */ static get eventBaseUrl() { return "https://api.hubapi.com/marketing/v3/marketing-events/events"; } /** * HubSpot Marketing Events API base URL for bulk operations. * @returns {string} */ static get bulkBaseUrl() { return "https://api.hubapi.com/marketing/v3/marketing-events/"; } /** * HubSpot Marketing Events API base URL for attendance operations. * @returns {string} */ static get attendanceBaseUrl() { return "https://api.hubapi.com/marketing/v3/marketing-events/attendance"; } /** * Hubspot External Marketing Events API base URL for participant breakdown operations. * @returns {string} */ static get breakdownBaseUrl() { return "https://api.hubapi.com/marketing/v3/marketing-events/participations/"; } /** * Creates or updates a marketing event in HubSpot by the externalEventId. * If the event exists, it is updated; otherwise, it is created. * @async * @param {Object} payload The event data. Must include externalEventId, eventName, eventOrganizer. * @param {string|number} payload.externalEventId * @param {string} payload.eventName * @param {string} payload.eventOrganizer * @returns {Promise<EventResult>} Object containing HubSpot response data and plugin execution results. * @throws {Error} If required fields are missing or API call fails. */ async createEvent(payload = {}) { await this.#pluginInit; // change some naming for my sanity if (payload.name && !payload.eventName) payload.eventName = payload.name; const { eventName, eventOrganizer, externalEventId } = payload; if (!externalEventId) throw new Error("Payload must include 'externalEventId'"); if (!eventName) throw new Error("Payload must include 'eventName' or 'name'"); if (!eventOrganizer) throw new Error("Payload must include 'eventOrganizer'"); const url = `${externalEventId}`; const body = { ...payload, externalAccountId: this.#externalAccountId, }; try { // for qol, check if the event exists before creating let existed = false; try { const existingEvent = await this.getEvent(externalEventId); existed = !!existingEvent?.hubspot; } catch (err) { existed = false; } // idempotent upsert const response = await this.#client.put(url, body); this.#externalEventId = externalEventId; const payload = { ...response.data, externalEventId: externalEventId, externalAccountId: this.#externalAccountId, status: existed ? "updated" : "created", }; // run hooks and capture their return values const plugins = await runHooks( this.hookFlags, EVENTS.CREATE_EVENT, payload ); // return both HubSpot data and plugin results return { hubspot: payload, plugins, }; } catch (error) { // run error hook await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "createEvent", error, }); const data = error.response?.data; console.error("createEvent error:", data || error); throw new Error(data?.message || error.message); } } /** * Retrieves a marketing event by the externalEventId. * @async * @param {string|number} [externalEventId=this.#externalEventId] The external event ID to lookup. * @returns {Promise<EventResult|null>} Object containing HubSpot response data and plugin execution results, or null if not found. * @throws {Error} If required IDs are missing or API call fails. */ async getEvent(externalEventId = this.#externalEventId) { await this.#pluginInit; if (!externalEventId) throw new Error("Missing externalEventId for getEvent"); if (!this.#externalAccountId) throw new Error("Missing externalAccountId for getEvent"); try { const params = new URLSearchParams({ externalAccountId: this.#externalAccountId, }); const url = `${externalEventId}?${params.toString()}`; const response = await this.#client.get(url); const result = response.data; const plugins = await runHooks(this.hookFlags, EVENTS.GET_EVENT, { externalEventId, found: !!result, }); return { hubspot: result, plugins, }; } catch (error) { if (error.response?.status === 404) { const plugins = await runHooks(this.hookFlags, EVENTS.GET_EVENT, { externalEventId, found: false, }); console.error("Event not found:", externalEventId); return null; } await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "getEvent", error, }); console.error("getEvent error:", error.response?.data || error); throw new Error(error.response?.data?.message || error.message); } } /** * Retrieves all marketing events for the account. * @async * @param {Object} [options] Optional params (e.g., limit, offset). * @returns {Promise<EventsResult>} Object containing array of HubSpotEvent objects and plugin execution results. * @throws {Error} If required IDs are missing or API call fails. */ async getEvents(options = {}) { await this.#pluginInit; if (!this.#externalAccountId) throw new Error("Missing externalAccountId for getEvents"); try { const params = new URLSearchParams({ externalAccountId: this.#externalAccountId, ...options, }); const url = `${MarketingEvent.bulkBaseUrl}?${params.toString()}`; const response = await this.#client.get(url); const results = response.data.results || []; const plugins = await runHooks(this.hookFlags, EVENTS.GET_EVENTS, { count: results.length, events: results, options, }); return { hubspot: results, plugins, }; } catch (error) { await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "getEvents", error, }); const data = error.response?.data; if (error.response?.status === 404) { console.error("No events found for account:", this.#externalAccountId); const plugins = await runHooks(this.hookFlags, EVENTS.GET_EVENTS, { count: 0, }); return { hubspot: [], plugins, }; } console.error("getEvents error:", data || error); throw new Error(data?.message || error.message); } } /** * Deletes a marketing event by the externalEventId. * @async * @param {string|number} [externalEventId=this.#externalEventId] The external event ID to delete. * @returns {Promise<DeleteResult>} Object containing deletion confirmation and plugin execution results. * @throws {Error} If required IDs are missing or API call fails. */ async deleteEvent(externalEventId = this.#externalEventId) { await this.#pluginInit; if (!externalEventId) throw new Error("Missing externalEventId for deleteEvent"); if (!this.#externalAccountId) throw new Error("Missing externalAccountId for deleteEvent"); try { const params = new URLSearchParams({ externalAccountId: this.#externalAccountId, }); const url = `${externalEventId}?${params.toString()}`; await this.#client.delete(url); // run hooks const plugins = await runHooks(this.hookFlags, EVENTS.DELETE_EVENT, { externalEventId, success: true, }); return { hubspot: { success: true }, plugins, }; } catch (error) { await runHooks(this.hookFlags, EVENTS.DELETE_EVENT, { externalEventId, success: false, }); await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "deleteEvent", error, }); console.error("deleteEvent error:", error.response?.data || error); throw new Error(error.response?.data?.message || error.message); } } /** * Registers or cancels a contact for an event by email (list-based). * If the contact does not exist, it will be created. * @async * @param {string} email The email address to register or cancel. * @param {string|number} [externalEventId=this.#externalEventId] The external event ID. * @param {number} [subscriberState=SUBSCRIBER_STATE.REGISTERED] The subscriber state (REGISTERED, CANCELED, ATTENDED). * @param {string} [fullName=""] Optional full name for contact creation if not existing. * @returns {Promise<RegisterEmailResultWithPlugins>} Object containing registration result and plugin execution results. * @throws {Error} If required fields are missing or API call fails. */ async registerEmail( email, externalEventId = this.#externalEventId, subscriberState = SUBSCRIBER_STATE.REGISTERED, fullName = "" ) { await this.#pluginInit; if (!email) throw new Error("Missing email for registerEmail"); if (!externalEventId) throw new Error("Missing externalEventId for registerEmail"); let contactId; try { const searchRes = await this.#client.post( "https://api.hubapi.com/crm/v3/objects/contacts/search", { filterGroups: [ { filters: [ { propertyName: "email", operator: "EQ", value: email, }, ], }, ], properties: ["email"], } ); const result = searchRes.data.results?.[0]; if (result?.id) { contactId = result.id; } } catch (e) { console.warn( `⚠️ Error searching contact:`, e.response?.data || e.message ); } if (!contactId) { console.log(`⚠️ Contact not found. Creating new contact for: ${email}`); const createRes = await this.#client.post( "https://api.hubapi.com/crm/v3/objects/contacts", { properties: { email, firstname: fullName.split(" ")[0] || "", lastname: fullName.split(" ")[1] || "", }, } ); contactId = createRes.data.id; } // list names const registeredListName = MarketingEvent.formatListName( externalEventId, SUBSCRIBER_STATE.REGISTERED ); const canceledListName = MarketingEvent.formatListName( externalEventId, SUBSCRIBER_STATE.CANCELED ); const attendingListName = MarketingEvent.formatListName( externalEventId, SUBSCRIBER_STATE.ATTENDED ); // just FIND lists by name const registeredListId = ( await this.createOrFindContactList(registeredListName) ).hubspot.listId; const canceledListId = ( await this.createOrFindContactList(canceledListName) ).hubspot.listId; const attendingListId = ( await this.createOrFindContactList(attendingListName) ).hubspot.listId; // prep batch operations for speed const addOps = []; const removeOps = []; if (subscriberState === SUBSCRIBER_STATE.REGISTERED) { addOps.push(this.addContactToList(registeredListId, contactId)); removeOps.push( this.removeContactFromList(canceledListId, contactId), this.removeContactFromList(attendingListId, contactId) ); } else if (subscriberState === SUBSCRIBER_STATE.CANCELED) { addOps.push(this.addContactToList(canceledListId, contactId)); removeOps.push( this.removeContactFromList(registeredListId, contactId), this.removeContactFromList(attendingListId, contactId) ); } else if (subscriberState === SUBSCRIBER_STATE.ATTENDED) { addOps.push(this.addContactToList(attendingListId, contactId)); removeOps.push( this.removeContactFromList(registeredListId, contactId), this.removeContactFromList(canceledListId, contactId) ); } // run ops in parallel for speed await Promise.all([...addOps, ...removeOps]); // run hooks const payload = { email, externalEventId, subscriberState, contactId, timestamp: Date.now(), registeredListId, canceledListId, attendingListId, registeredListName, canceledListName, attendingListName, }; const plugins = await runHooks( this.hookFlags, EVENTS.REGISTER_EMAIL, payload ); console.log( `✅ ${ subscriberState === SUBSCRIBER_STATE.REGISTERED ? "Registered" : subscriberState === SUBSCRIBER_STATE.CANCELED ? "Canceled" : "Attended" } contact: ${email} (ID: ${contactId}) for event: ${externalEventId}` ); return { hubspot: { success: true, email, subscriberState, contactId }, plugins, }; } /** * Returns all contacts of a certain type (subscriber state) for an event. * @async * @param {string|number} externalEventId The external event ID to lookup. * @param {number} subscriberState The subscriber state to filter by (e.g., SUBSCRIBER_STATE.REGISTERED). * @returns {Promise<ContactsResult>} Object containing array of HubSpotContact objects and plugin execution results. * @throws {Error} If required fields are missing or API call fails. */ async getContactsByState(externalEventId, subscriberState) { await this.#pluginInit; if (!externalEventId) throw new Error("Missing externalEventId for getContactsByState"); if (isNaN(subscriberState)) { throw new Error( "Missing or invalid subscriberState for getContactsByState" ); } const listName = MarketingEvent.formatListName( externalEventId, subscriberState ); const list = await this.createOrFindContactList(listName); const response = await this.#client.get( `https://api.hubapi.com/crm/v3/lists/${list.hubspot.listId}/memberships` ); const results = response.data.results || []; const plugins = await runHooks( this.hookFlags, EVENTS.GET_CONTACTS_BY_STATE, { externalEventId, subscriberState, count: results.length, } ); return { hubspot: results, plugins, }; } /** * Finds a contact list by name, or creates it if not found. * @async * @param {string} listName The name of the contact list to find or create. * @returns {Promise<ContactListResult>} Object containing the HubSpot contact list data and plugin execution results. * @throws {Error} If API call fails. */ async createOrFindContactList(listName) { await this.#pluginInit; try { const searchResponse = await this.#client.post( "https://api.hubapi.com/crm/v3/lists/search", { objectTypeId: "contact", query: listName, } ); const lists = searchResponse.data.lists || []; const found = lists.find((list) => list.name === listName); if (found) { const plugins = await runHooks( this.hookFlags, EVENTS.CREATE_OR_FIND_CONTACT_LIST, { listName, listId: found.listId, created: false, } ); return { hubspot: found, plugins, }; } } catch (e) { await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "createOrFindContactList", error: e, }); console.error( `Error searching for contact list "${listName}":`, e.response?.data || e.message ); } const createResponse = await this.#client.post( "https://api.hubapi.com/crm/v3/lists/", { objectTypeId: "contact", processingType: "MANUAL", name: listName, } ); const list = createResponse?.data?.list; const plugins = await runHooks( this.hookFlags, EVENTS.CREATE_OR_FIND_CONTACT_LIST, { list, created: true, } ); return { hubspot: list, plugins, }; } /** * See a contact's subscriber state for an event by email. * @async * @param {string} email The email address of the contact to lookup. * @param {string|number} externalEventId The external event ID to lookup. * @returns {Promise<ContactStateResult>} Object containing the contact's subscriber state and plugin execution results. * @throws {Error} If required fields are missing or API call fails. */ async getContactEventState(email, externalEventId) { await this.#pluginInit; if (!externalEventId) throw new Error("Missing externalEventId for getContactEventState"); if (!email) throw new Error("Missing email for getContactEventState"); // use ContactManager to find the contact by email const instance = new ContactManager(); let contact = await instance.getContactByEmail(email); // its faster to search in the 3 lists because we already have the contact ID const contactId = contact.id; if (!contactId) { console.warn(`Contact with email ${email} not found.`); const plugins = await runHooks( this.hookFlags, EVENTS.GET_CONTACT_EVENT_STATE, { email, externalEventId, state: null, } ); return { hubspot: null, plugins, }; } // for each list type (3), get the memberships const listTypes = [ SUBSCRIBER_STATE.REGISTERED, SUBSCRIBER_STATE.CANCELED, SUBSCRIBER_STATE.ATTENDED, ]; const memberships = {}; let mostRecentTimestamp = null; let mostRecentType = null; const membershipPromises = listTypes.map((type) => this.getContactsByState(externalEventId, type) ); const membershipResults = await Promise.all(membershipPromises); for (let i = 0; i < listTypes.length; i++) { const type = listTypes[i]; memberships[type] = membershipResults[i].hubspot.find( (m) => m.recordId === contactId ); if (memberships[type] && memberships[type].membershipTimestamp) { if ( mostRecentTimestamp === null || memberships[type].membershipTimestamp > mostRecentTimestamp ) { mostRecentTimestamp = memberships[type].membershipTimestamp; mostRecentType = type; } } } const plugins = await runHooks( this.hookFlags, EVENTS.GET_CONTACT_EVENT_STATE, { email, externalEventId, state: mostRecentType, } ); return { hubspot: isNaN(mostRecentType) ? null : mostRecentType, plugins, }; } /** * Adds a contact to a list. * @async * @param {string} listId The list ID. * @param {string} contactId The contact ID. * @returns {Promise<void>} * @throws {Error} If API call fails. */ async addContactToList(listId, contactId) { await this.#pluginInit; try { await this.#client.put( `https://api.hubapi.com/crm/v3/lists/${listId}/memberships/add`, [contactId] ); await runHooks(this.hookFlags, EVENTS.ADD_CONTACT_TO_LIST, { listId, contactId, }); } catch (error) { await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "addContactToList", error, }); throw error; } } /** * Removes a contact from a list. * @async * @param {string} listId The list ID. * @param {string} contactId The contact ID. * @returns {Promise<void>} * @throws {Error} If API call fails. */ async removeContactFromList(listId, contactId) { await this.#pluginInit; try { await this.#client.put( `https://api.hubapi.com/crm/v3/lists/${listId}/memberships/remove`, [contactId] ); await runHooks(this.hookFlags, EVENTS.REMOVE_CONTACT_FROM_LIST, { listId, contactId, }); } catch (error) { await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "removeContactFromList", error, }); throw error; } } /** * Associates a list with a marketing event. * @async * @param {string|number} objectId The event object ID. * @param {string} listId The list ID. * @returns {Promise<{hubspot: {success: boolean}, plugins: PluginResult[]}>} * @throws {Error} If API call fails. */ async associateListWithEvent(objectId, listId) { await this.#pluginInit; let success = true; try { await this.#client.put( `https://api.hubapi.com/marketing/v3/marketing-events/associations/${objectId}/lists/${listId}` ); } catch (error) { success = false; await runHooks(this.hookFlags, EVENTS.MARKETING_EVENT_ERROR, { action: "associateListWithEvent", error, }); // run plugin hook with failure const plugins = await runHooks( this.hookFlags, EVENTS.ASSOCIATE_LIST_WITH_EVENT, { objectId, listId, success: false, error, } ); return { hubspot: { success: false }, plugins, }; } // run plugin hook with success const plugins = await runHooks( this.hookFlags, EVENTS.ASSOCIATE_LIST_WITH_EVENT, { objectId, listId, success: true, } ); return { hubspot: { success: true }, plugins, }; } /** * Converts a numeric subscriber state to a human-readable string. * @param {number} state * @returns {SubscriberStateName|"UNKNOWN"} */ getSubscriberStateName(state) { switch (state) { case SUBSCRIBER_STATE.REGISTERED: return "REGISTERED"; case SUBSCRIBER_STATE.ATTENDED: return "ATTENDED"; case SUBSCRIBER_STATE.CANCELED: return "CANCELED"; default: return "UNKNOWN"; } } /** * Helper method to code the names for the contact lists. * @param {string|number} externalEventId * @param {number} type * @returns {string} The formatted list name. */ static formatListName(externalEventId, type) { // convert type to char const typeCode = type == 1 ? "A" : type == 2 ? "R" : "C"; return `E-${externalEventId}-${typeCode}`; } /** * Returns the stored external event ID. * @returns {string|number|null} */ get externalEventId() { return this.#externalEventId; } /** * Returns the stored external account ID. * @returns {string|null} */ get externalAccountId() { return this.#externalAccountId; } }