Skip to content

Instantly share code, notes, and snippets.

@dmurawsky
Created October 19, 2024 21:43
Show Gist options
  • Save dmurawsky/3138b91984ffa7d93819b0821a8c59ac to your computer and use it in GitHub Desktop.
Save dmurawsky/3138b91984ffa7d93819b0821a8c59ac to your computer and use it in GitHub Desktop.
Example of HubSpot integration
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