Created
October 19, 2024 21:43
-
-
Save dmurawsky/3138b91984ffa7d93819b0821a8c59ac to your computer and use it in GitHub Desktop.
Example of HubSpot integration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Client as HubspotClient } from "@hubspot/api-client"; | |
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders } from "axios"; | |
// Helpers | |
import { CrudOps, dynamicAxiosRequest } from "../isomorphic/dynamicAxiosRequest"; | |
import { replaceTemplateValues } from "../templateUtils"; | |
// Constants | |
import { | |
HUBSPOT_CONTACT_ASSOCIATION_ID, | |
HUBSPOT_OR_LIMIT, | |
HUBSPOT_PATHS, | |
HUBSPOT_SECONDLY_LIMIT, | |
HUBSPOT_TEN_SECOND_LIMIT, | |
NATIVE_LABELS, | |
ONE_SECOND, | |
SUCCESS_STATUS_CODES, | |
TEN_SECONDS, | |
} from "../constants"; | |
// Types | |
import type { FilterOperatorEnum, SimplePublicObject } from "@hubspot/api-client/lib/codegen/crm/objects"; | |
import type { PropertyCreate } from "@hubspot/api-client/lib/codegen/crm/properties"; | |
import type { ObjectSchemaEgg } from "@hubspot/api-client/lib/codegen/crm/schemas"; | |
import type { TimelineEvent } from "@hubspot/api-client/lib/codegen/crm/timeline"; | |
import type { | |
HubSpotCommunicationDefinition, | |
HubspotAccountDetails, | |
HubspotRequestBody, | |
HubspotUpdate, | |
MinutelyTrackingData, | |
ObjectSchemaDetailed, | |
RequestTemplate, | |
SchemaPropertyGroup, | |
} from "../types"; | |
import { get, remove, set } from "@hyfn/ashpack-soil/services/firebase-admin"; | |
import type { Maybe, RequireAtLeastOne } from "@hyfn/ashpack-tsconfig/globalTypeFallbacks"; | |
import { captureException } from "@sentry/nextjs"; | |
import { getEncodedEmail } from "services/utils"; | |
import { generateFuzzyPhoneNumbers } from "./hubspot-utils"; | |
import { slackMessage } from "./slackMessage"; | |
import type { CronContext, UpdateCronContext } from "./types"; | |
const BUFFER = ONE_SECOND * 5; | |
type TimeLeftPrevention = { | |
timeLeft: () => number; | |
updateCronContext: (cb: (cronCtx: CronContext) => CronContext) => void; | |
}; | |
type AssociateObjects = { | |
accessTokenInstance: AxiosInstance; | |
fromType: string; | |
fromId: string; | |
toType: string; | |
toId: string; | |
associationType: string; | |
}; | |
const MAX_RETRIES = 1; | |
const DELIMITER = "__"; | |
/* eslint-disable no-param-reassign */ | |
export const getAxiosHubspotRateLimiter = | |
( | |
secondlyCallTimes: number[], | |
tenSecondCallTimes: number[], | |
tracker: MinutelyTrackingData["hubspotTracker"], | |
opts: { timeLeftPrevention?: TimeLeftPrevention; secondlyLimit?: number; tenSecondLimit?: number } = {} | |
) => | |
async (config: AxiosRequestConfig) => { | |
const { | |
timeLeftPrevention, | |
secondlyLimit = HUBSPOT_SECONDLY_LIMIT, | |
tenSecondLimit = HUBSPOT_TEN_SECOND_LIMIT, | |
} = opts; | |
if (timeLeftPrevention) config.timeout = timeLeftPrevention.timeLeft(); | |
// * ie. /api/path/{args}__objectTypeId | |
const [hubspotPath, objectTypeIdOrOther] = String(config.headers?.hubspotTracking).split(DELIMITER); | |
if (hubspotPath && objectTypeIdOrOther) { | |
tracker[hubspotPath] = { | |
...tracker[hubspotPath], | |
[objectTypeIdOrOther]: (tracker[hubspotPath]?.[objectTypeIdOrOther] ?? 0) + 1, | |
}; | |
} | |
const now = Date.now(); | |
tenSecondCallTimes.push(now); | |
secondlyCallTimes.push(now); | |
// If this call would have possibly hit the 1 second limit... | |
if (secondlyCallTimes.length >= secondlyLimit) { | |
// ...and the earliest call within the limit was made at least 1 second ago... | |
const firstCallWithinLimit = secondlyCallTimes[secondlyCallTimes.length - 1 - secondlyLimit]; | |
if (now - firstCallWithinLimit <= ONE_SECOND) { | |
// ...sleep for 1 second. | |
await new Promise((res) => setTimeout(res, ONE_SECOND)); | |
const newNow = Date.now(); | |
secondlyCallTimes.length = 0; | |
tenSecondCallTimes.length = 0; | |
secondlyCallTimes.push(newNow); | |
tenSecondCallTimes.push(newNow); | |
return config; | |
} | |
} | |
// If this call would have possibly hit the 10 second limit... | |
if (tenSecondCallTimes.length >= tenSecondLimit) { | |
// ...and the earliest call within the limit was made at least a 10 seconds ago... | |
const firstCallWithinLimit = tenSecondCallTimes[tenSecondCallTimes.length - 1 - tenSecondLimit]; | |
if (now - firstCallWithinLimit <= TEN_SECONDS) { | |
// ...first check if there is enough time left to wait, and if there is... | |
if (timeLeftPrevention && timeLeftPrevention.timeLeft() <= TEN_SECONDS + BUFFER) { | |
timeLeftPrevention.updateCronContext((prev) => ({ | |
...prev, | |
status: SUCCESS_STATUS_CODES.PREVENT_TIMEOUT, | |
json: { | |
message: "success", | |
success: true, | |
version: "prevent processing timeout", | |
}, | |
})); | |
throw { isCronException: true }; | |
} | |
// ...sleep for 10 seconds. | |
await new Promise((res) => setTimeout(res, TEN_SECONDS)); | |
const newNow = Date.now(); | |
tenSecondCallTimes.length = 0; | |
tenSecondCallTimes.push(newNow); | |
return config; | |
} | |
} | |
return config; | |
}; | |
/* eslint-enable no-param-reassign */ | |
/* eslint-disable no-param-reassign */ | |
const getAxiosRetryInterceptor = | |
( | |
axiosInstance: AxiosInstance, | |
attempts: Record<string, Maybe<number>>, | |
opts: { retryTimeout: number; retryAllFailures: boolean; timeLeftPrevention?: TimeLeftPrevention } | |
) => | |
async (error: AxiosError<{ body?: { category?: string }; category?: string; errorType?: string }>) => { | |
const { retryTimeout, retryAllFailures, timeLeftPrevention } = opts; | |
if (timeLeftPrevention && timeLeftPrevention.timeLeft() <= BUFFER) { | |
timeLeftPrevention.updateCronContext((prev) => ({ | |
...prev, | |
status: SUCCESS_STATUS_CODES.PREVENT_TIMEOUT, | |
json: { | |
message: "success", | |
success: true, | |
version: "prevent processing timeout", | |
}, | |
})); | |
throw { isCronException: true }; | |
} | |
const { | |
message, | |
response, | |
config: { method, url, data, headers: retryHeaders }, | |
} = error; | |
const retryKey = `${method}||${url}||${data}`; | |
const attempt = attempts[retryKey] || 0; | |
const validRetry = | |
response?.data?.category === "RATE_LIMITS" || | |
response?.data?.errorType === "RATE_LIMIT" || | |
message.search(/timeout of \w* exceeded/) !== -1 || | |
retryAllFailures; | |
if (attempt < MAX_RETRIES && validRetry && method && url) { | |
attempts[retryKey] = (attempts[retryKey] || 0) + 1; | |
await new Promise((res) => setTimeout(res, retryTimeout)); | |
const payload = data ? JSON.parse(data) : undefined; | |
return dynamicAxiosRequest({ | |
axiosInstance, | |
method: method as CrudOps, | |
url, | |
options: { headers: retryHeaders }, | |
payload, | |
}).then((res) => { | |
delete attempts[retryKey]; | |
return res; | |
}); | |
} | |
throw error; | |
}; | |
export const getHubspotAxiosInstance = ( | |
accessToken: string, | |
{ | |
timeLeftPrevention, | |
retryTimeout = TEN_SECONDS, | |
retryAllFailures = false, | |
}: { timeLeftPrevention?: TimeLeftPrevention; retryTimeout?: number; retryAllFailures?: boolean } = {} | |
) => { | |
// TODO: We start with 2 because we aren't tracking this because we're using the library instead of the API | |
const tracker: MinutelyTrackingData["hubspotTracker"] = { requestToGetSchemas: { other: 2 } }; | |
const headers: AxiosRequestHeaders = { | |
"Content-Type": "application/json", | |
Authorization: `Bearer ${accessToken}`, | |
}; | |
const axiosInstance = axios.create({ baseURL: "https://api.hubapi.com", headers }); | |
const secondlyCallTimes: number[] = []; | |
const tenSecondCallTimes: number[] = []; | |
axiosInstance.interceptors.request.use( | |
getAxiosHubspotRateLimiter(secondlyCallTimes, tenSecondCallTimes, tracker, { timeLeftPrevention }) | |
); | |
const attempts: Record<string, Maybe<number>> = {}; | |
axiosInstance.interceptors.response.use( | |
(response) => response, | |
getAxiosRetryInterceptor(axiosInstance, attempts, { retryTimeout, retryAllFailures, timeLeftPrevention }) | |
); | |
return { axiosInstance, tracker, attempts }; | |
}; | |
export const getHubspotAppAccountDetails = (accessTokenInstance: AxiosInstance) => | |
accessTokenInstance.get<HubspotAccountDetails>("/integrations/v1/me").then(({ data }) => data); | |
/* | |
████████╗██╗███╗ ███╗███████╗██╗ ██╗███╗ ██╗███████╗ | |
╚══██╔══╝██║████╗ ████║██╔════╝██║ ██║████╗ ██║██╔════╝ | |
██║ ██║██╔████╔██║█████╗ ██║ ██║██╔██╗ ██║█████╗ | |
██║ ██║██║╚██╔╝██║██╔══╝ ██║ ██║██║╚██╗██║██╔══╝ | |
██║ ██║██║ ╚═╝ ██║███████╗███████╗██║██║ ╚████║███████╗ | |
╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ | |
*/ | |
export const createApptTimelineEvent = (timelineEvent: TimelineEvent, accessToken: string) => | |
new HubspotClient({ accessToken }).crm.timeline.eventsApi.create(timelineEvent); | |
/* | |
███████╗ ██████╗██╗ ██╗███████╗███╗ ███╗ █████╗ ███████╗ | |
██╔════╝██╔════╝██║ ██║██╔════╝████╗ ████║██╔══██╗██╔════╝ | |
███████╗██║ ███████║█████╗ ██╔████╔██║███████║███████╗ | |
╚════██║██║ ██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║╚════██║ | |
███████║╚██████╗██║ ██║███████╗██║ ╚═╝ ██║██║ ██║███████║ | |
╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ | |
*/ | |
export const createProperties = (objectType: string, inputs: PropertyCreate[], accessToken: string) => | |
new HubspotClient({ accessToken }).crm.properties.batchApi.create(objectType, { inputs }); | |
export const createCustomObjectType = (accessTokenInstance: AxiosInstance, objectSchemaEgg: ObjectSchemaEgg) => | |
accessTokenInstance.post<ObjectSchemaDetailed>("/crm/v3/schemas", objectSchemaEgg).then(({ data }) => data); | |
export const getCustomObjectType = (accessTokenInstance: AxiosInstance, objectTypeId: string) => | |
accessTokenInstance.get<ObjectSchemaDetailed>(`/crm/v3/schemas/${objectTypeId}`).then(({ data }) => data); | |
/** | |
* You can only delete a custom object after all object instances of that type are deleted. | |
* If you need to create a new custom object with the same name as the deleted object, you must hard delete the schema. | |
* You can only delete a custom object type after all object instances of that type, associations, and custom object properties are deleted. | |
*/ | |
export const deleteCustomObjectType = ( | |
accessTokenInstance: AxiosInstance, | |
objectTypeId: string, | |
{ hardDelete }: { hardDelete: boolean } | |
) => | |
accessTokenInstance | |
.delete<void>(`/crm/v3/schemas/${objectTypeId}?archived=${String(hardDelete)}`) | |
.then(({ data }) => data); | |
export const updateCustomObjectType = ( | |
accessTokenInstance: AxiosInstance, | |
objectTypeId: string, | |
body: RequireAtLeastOne<{ | |
requiredProperties?: string[]; | |
searchableProperties?: string[]; | |
primaryDisplayProperty?: string[]; | |
secondaryDisplayProperties?: string[]; | |
}> | |
) => accessTokenInstance.patch<ObjectSchemaDetailed>(`/crm/v3/schemas/${objectTypeId}`, body).then(({ data }) => data); | |
const getAllCustomObjectTypes = (accessToken: string) => | |
new HubspotClient({ accessToken }).crm.schemas.coreApi.getAll().then(({ results }) => results) as Promise< | |
ObjectSchemaDetailed[] | |
>; | |
const getObjectSchema = (key: (typeof NATIVE_LABELS)[number], accessToken: string) => | |
new HubspotClient({ accessToken }).crm.schemas.coreApi.getById(key) as Promise<ObjectSchemaDetailed>; | |
export const getCustomAndNativeObjects = (accessToken: string, nativeObjectIncludeKeys: string[]) => | |
Promise.all([ | |
getAllCustomObjectTypes(accessToken), | |
...["contact", ...nativeObjectIncludeKeys].map((key) => | |
getObjectSchema(key as (typeof NATIVE_LABELS)[number], accessToken) | |
), | |
]).then(([customObjects, contact, ...rest]) => { | |
const newContact = { ...contact, objectTypeId: "contact" }; | |
return [...customObjects, newContact, ...(nativeObjectIncludeKeys.length ? rest : [])]; | |
}); | |
export const getSchemaPropertyGroups = (accessTokenInstance: AxiosInstance, schemaId: string) => | |
accessTokenInstance | |
.get<{ | |
results: SchemaPropertyGroup[]; | |
}>(`/crm/v3/properties/${schemaId}/groups`) | |
.then(({ data }) => data.results); | |
/* | |
██████╗ ██████╗ ██╗███████╗ ██████╗████████╗███████╗ | |
██╔═══██╗██╔══██╗ ██║██╔════╝██╔════╝╚══██╔══╝██╔════╝ | |
██║ ██║██████╔╝ ██║█████╗ ██║ ██║ ███████╗ | |
██║ ██║██╔══██╗██ ██║██╔══╝ ██║ ██║ ╚════██║ | |
╚██████╔╝██████╔╝╚█████╔╝███████╗╚██████╗ ██║ ███████║ | |
╚═════╝ ╚═════╝ ╚════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ | |
*/ | |
export type BehavioralAssociation = RequireAtLeastOne<{ email?: string; phone?: string; objectId?: string }>; | |
// ! #1 The Hubspot API sometimes uses `eventName` and sometimes uses `eventType` - this is beta so watch for changes | |
export const createCustomBehavioralEvent = ( | |
accessTokenInstance: AxiosInstance, | |
eventName: string, | |
association: BehavioralAssociation, | |
properties: HubspotRequestBody["properties"] | |
) => | |
accessTokenInstance | |
.post( | |
HUBSPOT_PATHS.sendBehavioralEvents, | |
{ eventName, properties, ...association }, | |
{ headers: { hubspotTracking: `createCustomBehavioralEvent${DELIMITER}${eventName}` } } | |
) | |
.then(({ data }) => data); | |
// * Danny - do not delete this ------------------------------------------------------------------- | |
// ! #2 The Hubspot API sometimes uses `eventName` and sometimes uses `eventType` - this is beta so watch for changes | |
// type BehavioralEventSearch = | |
// | { contactHubspotId: string; fields?: undefined } | |
// | { contactHubspotId?: undefined; fields: { email?: string; phone?: string } }; | |
// export const searchCustomBehavioralEvent = ( | |
// accessTokenInstance: AxiosInstance, | |
// eventType: string, | |
// { contactHubspotId, fields }: BehavioralEventSearch | |
// ) => | |
// accessTokenInstance | |
// .get<{ results: BehavioralEvent[] }>( | |
// `${HUBSPOT_PATHS.searchBehavioralEvents}${buildQueryParamsString({ | |
// objectType: "contact", | |
// eventType, | |
// objectId: contactHubspotId, | |
// "objectProperty.email": fields?.email, | |
// "objectProperty.phone": fields?.phone, | |
// })}`, | |
// { headers: { hubspotTracking: `searchCustomBehavioralEvent${DELIMITER}${eventType}` } } | |
// ) | |
// .then(({ data }) => data); | |
// * ---------------------------------------------------------------------------------------------- | |
export const mergeContacts = (accessTokenInstance: AxiosInstance, contactId: string, mergeId: string) => | |
accessTokenInstance | |
.post<"SUCCESS">( | |
replaceTemplateValues(HUBSPOT_PATHS.mergeContacts, { contactId }), | |
{ vidToMerge: mergeId }, | |
{ headers: { hubspotTracking: `mergeContacts${DELIMITER}contact` } } | |
) | |
.then(({ data }) => data); | |
export const createObject = (accessTokenInstance: AxiosInstance, objectType: string, object: HubspotRequestBody) => | |
accessTokenInstance | |
.post<SimplePublicObject>( | |
replaceTemplateValues(HUBSPOT_PATHS.createObject, { | |
objectType: objectType === "contact" ? "contacts" : objectType, | |
}), | |
object, | |
{ headers: { hubspotTracking: `createObject${DELIMITER}${objectType}` } } | |
) | |
.then(({ data }) => data); | |
export const updateObject = ( | |
accessTokenInstance: AxiosInstance, | |
objectType: string, | |
objectId: string, | |
object: HubspotRequestBody | |
) => { | |
const url = replaceTemplateValues(HUBSPOT_PATHS.updateObject, { | |
objectType: objectType === "contact" ? "contacts" : objectType, | |
objectId, | |
}); | |
const headers = { hubspotTracking: `updateObject${DELIMITER}${objectType}` }; | |
return accessTokenInstance.patch<SimplePublicObject>(url, object, { headers }).then(({ data }) => data); | |
}; | |
export const deleteObject = (accessTokenInstance: AxiosInstance, objectType: string, objectId: string) => | |
accessTokenInstance | |
.delete<SimplePublicObject>( | |
replaceTemplateValues(HUBSPOT_PATHS.deleteObject, { | |
objectType: objectType === "contact" ? "contacts" : objectType, | |
objectId, | |
}), | |
{ headers: { hubspotTracking: `deleteObject${DELIMITER}${objectType}` } } | |
) | |
.then(({ data }) => data); | |
export const batchDeleteObjects = (accessTokenInstance: AxiosInstance, objectType: string, objectIds: string[]) => | |
accessTokenInstance | |
.post<SimplePublicObject>( | |
replaceTemplateValues(HUBSPOT_PATHS.batchDeleteObjects, { | |
objectType: objectType === "contact" ? "contacts" : objectType, | |
}), | |
{ inputs: objectIds.map((id) => ({ id })) }, | |
{ headers: { hubspotTracking: `batchDeleteObjects${DELIMITER}${objectType}` } } | |
) | |
.then(({ data }) => data); | |
export const associateObjects = ({ | |
accessTokenInstance, | |
fromType, | |
fromId, | |
toType, | |
toId, | |
associationType, | |
}: AssociateObjects) => { | |
//FIX: Self Association should not be allowed | |
if (fromType == toType && fromId == toId) return true; | |
if (associationType === "roller_signed_waiver_to_contact") return true; | |
return accessTokenInstance | |
.put<unknown>( | |
replaceTemplateValues(HUBSPOT_PATHS.associateObjects, { | |
fromType: fromType === "contact" ? HUBSPOT_CONTACT_ASSOCIATION_ID : fromType, | |
fromId, | |
toType: toType === "contact" ? HUBSPOT_CONTACT_ASSOCIATION_ID : toType, | |
toId, | |
associationType, | |
}), | |
undefined, | |
{ headers: { hubspotTracking: `associateObjects${DELIMITER}${fromType}` } } | |
) | |
.then(({ data }) => data) | |
.catch((e) => { | |
captureException(e.response?.data || e, { | |
tags: { | |
section: "objects-association", | |
path: "services/server-side/hubspot.ts", | |
type: "associateObjects", | |
}, | |
contexts: { | |
associationDetails: { | |
fromType, | |
fromId, | |
toType, | |
toId, | |
associationType, | |
}, | |
}, | |
}); | |
if (e.response?.data?.context?.ASSOCIATION_LIMIT_EXCEEDED) { | |
// eslint-disable-next-line prettier/prettier, no-console | |
console.error("ASSOCIATION_LIMIT_EXCEEDED", e.response.data); | |
return slackMessage({ | |
title: "Skipped association because `associateObjects` got error: ASSOCIATION_LIMIT_EXCEEDED", | |
json: { | |
fromType, | |
fromId, | |
toType, | |
toId, | |
associationType, | |
}, | |
}); | |
} | |
throw e; | |
}); | |
}; | |
type FilterGroup = { | |
value: string; | |
propertyName: string; | |
operator?: Exclude<FilterOperatorEnum, "IN" | "NOT_IN">; | |
}; | |
type FilterGroupList = { | |
values: string[]; | |
propertyName: string; | |
operator: Extract<FilterOperatorEnum, "IN" | "NOT_IN">; | |
}; | |
const isFilterGroup = (group: FilterGroup | FilterGroupList): group is FilterGroup => | |
Boolean((group as FilterGroup).value); | |
type SearchAll = { | |
accessTokenInstance: AxiosInstance; | |
objectTypeId: string; | |
props: Mandate<HubspotUpdate, "objectProperties">["objectProperties"]; | |
findInHubspot: Mandate<Mandate<RequestTemplate, "hubspotUpdate">["hubspotUpdate"], "findInHubspot">["findInHubspot"]; | |
useFuzzyPhoneSearch: boolean; | |
}; | |
async function saveBatchInCache(cachePaths: string[], results: Maybe<SimplePublicObject>[]): Promise<void> { | |
const getCurrentDateISO = () => new Date().toISOString(); | |
const updatedResults = results.map((item) => ({ | |
...item, | |
createdCacheAt: getCurrentDateISO(), | |
})); | |
const promises = cachePaths.map((path) => set(path, updatedResults)); | |
await Promise.all(promises); | |
} | |
async function deleteBatchInCache(cachePaths: string[]) { | |
const promises = cachePaths.map((path) => remove(path)); | |
await Promise.all(promises); | |
} | |
function getCachePaths( | |
searchAttemptProperties: string[], | |
props: Record<string, string | number | null>, | |
objectTypeId: string | |
): { cachePaths: string[]; phoneValues: FilterGroupList["values"]; filterGroups: FilterGroup[] } { | |
const cachePaths: string[] = []; | |
const phoneValues: FilterGroupList["values"] = []; | |
const filterGroups: FilterGroup[] = []; | |
if (!searchAttemptProperties.includes("roller_customer_id")) { | |
searchAttemptProperties | |
.filter((propName) => props[propName] != null && props[propName] !== "") | |
.forEach((propName) => { | |
if (propName === "phone") phoneValues.push(String(props[propName])); // this will only happen once | |
else filterGroups.push({ value: String(props[propName]), propertyName: propName }); | |
cachePaths.push( | |
`hubspotObjectCache/${objectTypeId}/${propName}/${getEncodedEmail( | |
String(props[propName]), | |
propName === "email" | |
)}` | |
); | |
}); | |
} | |
return { cachePaths, phoneValues, filterGroups }; | |
} | |
export const searchHubspotObjByProperty = async ( | |
accessTokenInstance: AxiosInstance, | |
objectTypeId: string, | |
filterGroups: (FilterGroup | FilterGroupList)[], | |
properties: string[], | |
cachePaths: string[] | |
): Promise<Maybe<SimplePublicObject>[] | undefined> => { | |
const createFilterGroupBatch = (batch: (FilterGroup | FilterGroupList)[]) => | |
batch.map((group) => { | |
if (isFilterGroup(group)) { | |
const { value, propertyName, operator = "EQ" } = group; | |
return { filters: [{ value: value.trim().toLowerCase(), propertyName, operator }] }; | |
} | |
const { values, propertyName, operator } = group; | |
return { filters: [{ values, propertyName, operator }] }; | |
}); | |
for (let i = 0; i < filterGroups.length; i += HUBSPOT_OR_LIMIT) { | |
const batch = filterGroups.slice(i, i + HUBSPOT_OR_LIMIT); | |
const filterGroupBatch = createFilterGroupBatch(batch); | |
// eslint-disable-next-line no-await-in-loop | |
const results = await accessTokenInstance | |
.post<{ results: Maybe<SimplePublicObject>[] }>( | |
replaceTemplateValues(HUBSPOT_PATHS.searchHubspotObjByProperty, { | |
objectTypeId: objectTypeId === "contact" ? "contacts" : objectTypeId, | |
}), | |
{ filterGroups: filterGroupBatch, properties }, | |
{ headers: { hubspotTracking: `searchHubspotObjByProperty${DELIMITER}${objectTypeId}` } } | |
) | |
.then(({ data }) => data.results); | |
if (results?.length) { | |
// eslint-disable-next-line no-await-in-loop | |
await saveBatchInCache(cachePaths, results); | |
return results; | |
} | |
} | |
return undefined; | |
}; | |
export async function searchHubspotAndUpdateCache(search: SearchAll) { | |
const { props, objectTypeId, findInHubspot, accessTokenInstance, useFuzzyPhoneSearch } = search; | |
const { searchAttemptProperties, returnProperties: returnProps = [] } = findInHubspot; | |
const { cachePaths, filterGroups, phoneValues } = getCachePaths(searchAttemptProperties, props, objectTypeId); | |
// Clean cache | |
await deleteBatchInCache(cachePaths); | |
// Search new values from Hubspot and update cache in firebase | |
let results = await searchHubspotObjByProperty( | |
accessTokenInstance, | |
objectTypeId, | |
filterGroups, | |
returnProps, | |
cachePaths | |
); | |
if (!results?.length && phoneValues[0]) { | |
if (useFuzzyPhoneSearch) phoneValues.push(...generateFuzzyPhoneNumbers(phoneValues[0])); | |
const phoneGroup = { values: phoneValues, propertyName: "phone", operator: "IN" } as const; | |
results = await searchHubspotObjByProperty( | |
accessTokenInstance, | |
objectTypeId, | |
[phoneGroup], | |
returnProps, | |
cachePaths | |
); | |
} | |
const isContactSearch = ["contacts", "contact", "0-1"].includes(objectTypeId); | |
if (!results?.length && isContactSearch && props.email) { | |
if (searchAttemptProperties.includes("email")) { | |
const alternateEmailGroup = { | |
propertyName: "hs_additional_emails", | |
value: String(props.email), | |
operator: "CONTAINS_TOKEN", | |
} as const; | |
results = await searchHubspotObjByProperty( | |
accessTokenInstance, | |
objectTypeId, | |
[alternateEmailGroup], | |
returnProps, | |
cachePaths | |
); | |
} else { | |
/* | |
/ Sometimes at this point, we have the email property but it doesn't exist in "searchAttemptProperties" | |
/ Then we don't search for the email and try to create a new object failing, because the email already exists. | |
/ Now we check that here. | |
*/ | |
const emailGroup = { | |
propertyName: "email", | |
value: String(props.email), | |
operator: "EQ", | |
} as const; | |
results = await searchHubspotObjByProperty( | |
accessTokenInstance, | |
objectTypeId, | |
[emailGroup], | |
returnProps, | |
cachePaths | |
); | |
} | |
} | |
return results; | |
} | |
async function searchCache(search: SearchAll) { | |
const { cachePaths } = getCachePaths(search.findInHubspot.searchAttemptProperties, search.props, search.objectTypeId); | |
const cachePromises = cachePaths.map((cachePath) => get<Maybe<SimplePublicObject>[]>(cachePath)); | |
const cacheResults = await Promise.all(cachePromises); | |
const cacheValues = cacheResults.filter((result) => result !== null).flat(); | |
return cacheValues; | |
} | |
export const searchAll = async ({ | |
accessTokenInstance, | |
objectTypeId, | |
props, | |
findInHubspot: { searchAttemptProperties, returnProperties }, | |
useFuzzyPhoneSearch, | |
}: SearchAll) => { | |
// TODO: Search Order is not respected here, need to work on it | |
// TODO: remove this if condition after resolving the contacts issue in Roller | |
const cacheValues: (Maybe<SimplePublicObject> | null)[] = await searchCache({ | |
accessTokenInstance, | |
objectTypeId, | |
props, | |
findInHubspot: { searchAttemptProperties, returnProperties }, | |
useFuzzyPhoneSearch, | |
}); | |
if (cacheValues?.length) return cacheValues; | |
const results = await searchHubspotAndUpdateCache({ | |
accessTokenInstance, | |
objectTypeId, | |
props, | |
findInHubspot: { searchAttemptProperties, returnProperties }, | |
useFuzzyPhoneSearch, | |
}); | |
if (results?.length) return results; | |
return null; | |
}; | |
/* | |
███████╗██╗ ██╗██████╗ ███████╗ ██████╗██████╗ ██╗██████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗ | |
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔══██╗██║██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝ | |
███████╗██║ ██║██████╔╝███████╗██║ ██████╔╝██║██████╔╝ ██║ ██║██║ ██║██╔██╗ ██║███████╗ | |
╚════██║██║ ██║██╔══██╗╚════██║██║ ██╔══██╗██║██╔═══╝ ██║ ██║██║ ██║██║╚██╗██║╚════██║ | |
███████║╚██████╔╝██████╔╝███████║╚██████╗██║ ██║██║██║ ██║ ██║╚██████╔╝██║ ╚████║███████║ | |
╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ | |
*/ | |
export const getSubscriptionDefinitions = async (accessTokenInstance: AxiosInstance) => | |
accessTokenInstance | |
.get<{ subscriptionDefinitions: HubSpotCommunicationDefinition[] }>(HUBSPOT_PATHS.getSubscriptionDefinitions, { | |
headers: { | |
hubspotTracking: `getSubscriptionDefinitions${DELIMITER}other`, | |
}, | |
}) | |
.then(({ data: { subscriptionDefinitions } }) => subscriptionDefinitions); | |
export const updateCommunicationPreferences = async ( | |
accessTokenInstance: AxiosInstance, | |
subscriptionDefinitions: HubSpotCommunicationDefinition[], | |
emailAddress: string, | |
version: "subscribe" | "unsubscribe", | |
updateCronContext: UpdateCronContext | |
) => { | |
updateCronContext((prev) => ({ | |
...prev, | |
function: "updateCommunicationPreferences", | |
emailAddress, | |
})); | |
await Promise.all( | |
subscriptionDefinitions | |
.filter(({ isInternal }) => !isInternal) | |
.map(({ id: subscriptionId }) => | |
accessTokenInstance | |
.post( | |
replaceTemplateValues(HUBSPOT_PATHS.updateCommunicationPreferences, { version }), | |
{ | |
emailAddress, | |
subscriptionId, | |
legalBasis: "LEGITIMATE_INTEREST_CLIENT", | |
}, | |
{ | |
headers: { | |
hubspotTracking: `searchHubspotObjByProperty${DELIMITER}contact`, | |
}, | |
} | |
) | |
.catch((e) => { | |
// If they are already the subscription state we are setting, it throws a validation error (ie. already subscribed) | |
const { category, message } = (e as AxiosError<{ category: string; message: string }>).response?.data || {}; | |
if ( | |
category === "VALIDATION_ERROR" && | |
(message?.match(/is already (unsubscribed from|subscribed to) subscription/) || | |
message?.match("cannot be updated because they have unsubscribed")) | |
) { | |
// eslint-disable-next-line no-console | |
console.log((e as AxiosError).response?.data); | |
} else { | |
throw e; | |
} | |
}) | |
) | |
); | |
updateCronContext((prev) => { | |
delete prev.emailAddress; // eslint-disable-line no-param-reassign | |
return prev; | |
}); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment