Skip to content

Instantly share code, notes, and snippets.

@dmurawsky
Created October 19, 2024 21:43
Show Gist options
  • Select an option

  • Save dmurawsky/3138b91984ffa7d93819b0821a8c59ac to your computer and use it in GitHub Desktop.

Select an option

Save dmurawsky/3138b91984ffa7d93819b0821a8c59ac to your computer and use it in GitHub Desktop.

Revisions

  1. dmurawsky created this gist Oct 19, 2024.
    776 changes: 776 additions & 0 deletions hubspot-integration.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,776 @@
    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;
    });
    };