mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* refactor: move repositories from lib to features domain folders - Move HolidayRepository to features/holidays/repositories - Move PrismaTrackingRepository to features/bookings/repositories - Move PrismaBookingPaymentRepository to features/bookings/repositories - Move PrismaRoutingFormResponseRepository to features/routing-forms/repositories - Move PrismaAssignmentReasonRepository to features/assignment-reason/repositories - Move VerificationTokenRepository to features/auth/repositories - Move WorkspacePlatformRepository to features/workspace-platform/repositories - Move DTO files to their respective feature domains - Merge lib DestinationCalendarRepository into features version - Merge lib SelectedCalendarRepository into features version - Update all import paths across the codebase This follows the vertical slice architecture pattern by organizing repositories by domain rather than by technical layer. Co-Authored-By: [email protected] <[email protected]> * fix: update VerificationTokenService import path to new location Co-Authored-By: [email protected] <[email protected]> * fix: update test file imports to use new repository locations Co-Authored-By: [email protected] <[email protected]> * mv * fix structure * fix * refactor: merge unit tests for SelectedCalendarRepository into single file Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
694 lines
24 KiB
TypeScript
694 lines
24 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
|
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
|
|
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
|
|
import { getLocation } from "@calcom/lib/CalEventParser";
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
import logger from "@calcom/lib/logger";
|
|
import { PrismaTrackingRepository } from "@calcom/features/bookings/repositories/PrismaTrackingRepository";
|
|
import prisma from "@calcom/prisma";
|
|
import type { CalEventResponses, CalendarEvent } from "@calcom/types/Calendar";
|
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
|
import type { Contact, ContactCreateInput, CRM, CrmEvent } from "@calcom/types/CrmService";
|
|
import * as hubspot from "@hubspot/api-client";
|
|
import type { BatchInputPublicAssociation } from "@hubspot/api-client/lib/codegen/crm/associations";
|
|
import type { PublicObjectSearchRequest } from "@hubspot/api-client/lib/codegen/crm/contacts";
|
|
import type {
|
|
SimplePublicObject,
|
|
SimplePublicObjectInput,
|
|
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
|
import type { z } from "zod";
|
|
import { CrmFieldType, DateFieldType } from "../../_lib/crm-enums";
|
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
|
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
|
import type { HubspotToken } from "../api/callback";
|
|
import type { appDataSchema } from "../zod";
|
|
|
|
class HubspotCalendarService implements CRM {
|
|
private url = "";
|
|
private integrationName = "";
|
|
private auth: Promise<{ getToken: () => Promise<HubspotToken | void | never[]> }>;
|
|
private log: typeof logger;
|
|
private client_id = "";
|
|
private client_secret = "";
|
|
private hubspotClient: hubspot.Client;
|
|
private appOptions: z.infer<typeof appDataSchema>;
|
|
private bookingRepository: BookingRepository;
|
|
private trackingRepository: PrismaTrackingRepository;
|
|
|
|
constructor(credential: CredentialPayload, appOptions?: z.infer<typeof appDataSchema>) {
|
|
this.hubspotClient = new hubspot.Client();
|
|
|
|
this.integrationName = "hubspot_other_calendar";
|
|
|
|
this.auth = this.hubspotAuth(credential).then((r) => r);
|
|
|
|
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
|
|
|
this.appOptions = appOptions || {};
|
|
this.bookingRepository = new BookingRepository(prisma);
|
|
this.trackingRepository = new PrismaTrackingRepository(prisma);
|
|
}
|
|
|
|
private getHubspotMeetingBody = (event: CalendarEvent): string => {
|
|
const userFields = getLabelValueMapFromResponses(event);
|
|
const plainText = event?.description?.replace(/<\/?[^>]+(>|$)/g, "").replace(/_/g, " ");
|
|
const location = getLocation({
|
|
videoCallData: event.videoCallData,
|
|
additionalInformation: event.additionalInformation,
|
|
location: event.location,
|
|
uid: event.uid,
|
|
});
|
|
const userFieldsHtml = Object.entries(userFields)
|
|
.map(([key, value]) => {
|
|
const formattedValue = typeof value === "boolean" ? (value ? "Yes" : "No") : value || "-";
|
|
return `<b>${event.organizer.language.translate(key)}:</b> ${formattedValue}`;
|
|
})
|
|
.join("<br><br>");
|
|
|
|
const organizerName = event.organizer.name || event.organizer.email;
|
|
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email})`;
|
|
|
|
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
|
|
event.attendees[0].timeZone
|
|
}<br><br>${
|
|
event.additionalNotes
|
|
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
|
|
event.additionalNotes
|
|
}<br><br>`
|
|
: ""
|
|
}
|
|
${userFieldsHtml}<br><br>
|
|
<b>${event.organizer.language.translate("where")}:</b> ${location}<br><br>
|
|
${plainText ? `<b>${event.organizer.language.translate("description")}</b><br>${plainText}` : ""}
|
|
`;
|
|
};
|
|
|
|
private async ensureFieldsExistOnMeeting(fieldsToTest: string[]) {
|
|
const log = logger.getSubLogger({ prefix: [`[ensureFieldsExistOnMeeting]`] });
|
|
const fieldSet = new Set(fieldsToTest);
|
|
const foundFields: Array<{ name: string; type: string; [key: string]: any }> = [];
|
|
|
|
try {
|
|
const properties = await this.hubspotClient.crm.properties.coreApi.getAll("meetings");
|
|
|
|
for (const property of properties.results) {
|
|
if (foundFields.length === fieldSet.size) break;
|
|
|
|
if (fieldSet.has(property.name)) {
|
|
foundFields.push(property);
|
|
}
|
|
}
|
|
|
|
const foundFieldNames = new Set(foundFields.map((f) => f.name));
|
|
const missingFields = fieldsToTest.filter((field) => !foundFieldNames.has(field));
|
|
|
|
if (missingFields.length > 0) {
|
|
log.warn(
|
|
`The following fields do not exist in HubSpot and will be skipped: ${missingFields.join(", ")}. Meeting creation will continue without these fields.`
|
|
);
|
|
}
|
|
|
|
return foundFields;
|
|
} catch (e: any) {
|
|
log.error(`Error ensuring fields ${fieldsToTest} exist on Meeting object with error ${e}`);
|
|
// Return empty array to gracefully degrade - meeting creation will proceed without custom field validation
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async getTextValueFromBookingTracking(fieldValue: string, bookingUid: string, fieldName: string) {
|
|
const log = logger.getSubLogger({
|
|
prefix: [`[getTextValueFromBookingTracking]: ${fieldName} - ${bookingUid}`],
|
|
});
|
|
|
|
const tracking = await this.trackingRepository.findByBookingUid(bookingUid);
|
|
|
|
if (!tracking) {
|
|
log.warn(`No tracking found for bookingUid ${bookingUid}`);
|
|
return "";
|
|
}
|
|
|
|
const utmParam = fieldValue.split(":")[1].slice(0, -1);
|
|
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
|
|
}
|
|
|
|
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
|
|
const regexValueToReplace = /\{(.*?)\}/g;
|
|
return fieldValue.replace(regexValueToReplace, (match, captured) => {
|
|
return calEventResponses[captured]?.value ? calEventResponses[captured].value.toString() : match;
|
|
});
|
|
}
|
|
|
|
private async getDateFieldValue(
|
|
fieldValue: string,
|
|
startTime?: string,
|
|
bookingUid?: string | null
|
|
): Promise<string | null> {
|
|
if (fieldValue === DateFieldType.BOOKING_START_DATE) {
|
|
if (!startTime) {
|
|
this.log.error("StartTime is required for BOOKING_START_DATE but was not provided");
|
|
return null;
|
|
}
|
|
return new Date(startTime).toISOString();
|
|
}
|
|
|
|
if (fieldValue === DateFieldType.BOOKING_CREATED_DATE && bookingUid) {
|
|
const booking = await this.bookingRepository.findBookingByUid({ bookingUid });
|
|
|
|
if (!booking) {
|
|
this.log.warn(`No booking found for ${bookingUid}`);
|
|
return null;
|
|
}
|
|
|
|
return new Date(booking.createdAt).toISOString();
|
|
}
|
|
|
|
if (fieldValue === DateFieldType.BOOKING_CANCEL_DATE) {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async getFieldValue({
|
|
fieldValue,
|
|
fieldType,
|
|
calEventResponses,
|
|
bookingUid,
|
|
startTime,
|
|
fieldName,
|
|
}: {
|
|
fieldValue: string | boolean;
|
|
fieldType: CrmFieldType;
|
|
calEventResponses?: CalEventResponses | null;
|
|
bookingUid?: string | null;
|
|
startTime?: string;
|
|
fieldName: string;
|
|
}): Promise<string | boolean | null> {
|
|
const log = logger.getSubLogger({ prefix: [`[getFieldValue]: ${fieldName}`] });
|
|
|
|
if (fieldType === CrmFieldType.CHECKBOX) {
|
|
return !!fieldValue;
|
|
}
|
|
|
|
if (fieldType === CrmFieldType.DATE || fieldType === CrmFieldType.DATETIME) {
|
|
return await this.getDateFieldValue(fieldValue as string, startTime, bookingUid);
|
|
}
|
|
|
|
if (
|
|
fieldType === CrmFieldType.TEXT ||
|
|
fieldType === CrmFieldType.STRING ||
|
|
fieldType === CrmFieldType.PHONE ||
|
|
fieldType === CrmFieldType.TEXTAREA ||
|
|
fieldType === CrmFieldType.CUSTOM
|
|
) {
|
|
if (typeof fieldValue !== "string") {
|
|
log.error(`Expected string value for field ${fieldName}, got ${typeof fieldValue}`);
|
|
return null;
|
|
}
|
|
|
|
if (!fieldValue.startsWith("{") && !fieldValue.endsWith("}")) {
|
|
log.info("Returning static value");
|
|
return fieldValue;
|
|
}
|
|
|
|
let valueToWrite = fieldValue;
|
|
|
|
// Extract from UTM tracking
|
|
if (fieldValue.startsWith("{utm:")) {
|
|
if (!bookingUid) {
|
|
log.error(`BookingUid not passed. Cannot get tracking values without it`);
|
|
return null;
|
|
}
|
|
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid, fieldName);
|
|
} else {
|
|
// Extract from booking form responses
|
|
if (!calEventResponses) {
|
|
log.error(`CalEventResponses not passed. Cannot get booking form responses`);
|
|
return null;
|
|
}
|
|
valueToWrite = this.getTextValueFromBookingResponse(fieldValue, calEventResponses);
|
|
}
|
|
|
|
if (valueToWrite === fieldValue) {
|
|
log.error("No responses found returning nothing");
|
|
return null;
|
|
}
|
|
|
|
return valueToWrite;
|
|
}
|
|
|
|
log.error(`Unsupported field type ${fieldType} for field ${fieldName}`);
|
|
return null;
|
|
}
|
|
|
|
private async generateWriteToMeetingBody(event: CalendarEvent) {
|
|
const appOptions = this.getAppOptions();
|
|
|
|
const customFieldInputsEnabled =
|
|
appOptions?.onBookingWriteToEventObject && appOptions?.onBookingWriteToEventObjectFields;
|
|
|
|
if (!customFieldInputsEnabled) return {};
|
|
|
|
if (!appOptions?.onBookingWriteToEventObjectFields) return {};
|
|
|
|
const customFieldInputs = customFieldInputsEnabled
|
|
? await this.ensureFieldsExistOnMeeting(Object.keys(appOptions.onBookingWriteToEventObjectFields))
|
|
: [];
|
|
|
|
const confirmedCustomFieldInputs: Record<string, any> = {};
|
|
|
|
for (const field of customFieldInputs) {
|
|
const fieldConfig = appOptions.onBookingWriteToEventObjectFields[field.name];
|
|
|
|
const fieldValue = await this.getFieldValue({
|
|
fieldValue: fieldConfig.value,
|
|
fieldType: fieldConfig.fieldType,
|
|
calEventResponses: event.responses,
|
|
bookingUid: event?.uid,
|
|
startTime: event.startTime,
|
|
fieldName: field.name,
|
|
});
|
|
|
|
if (fieldValue !== null) {
|
|
confirmedCustomFieldInputs[field.name] = fieldValue;
|
|
}
|
|
}
|
|
|
|
this.log.info(`Writing to meeting fields: ${Object.keys(confirmedCustomFieldInputs)}`);
|
|
|
|
return confirmedCustomFieldInputs;
|
|
}
|
|
|
|
private hubspotCreateMeeting = async (event: CalendarEvent, hubspotOwnerId?: string) => {
|
|
const writeToMeetingRecord = await this.generateWriteToMeetingBody(event);
|
|
|
|
const properties: Record<string, string> = {
|
|
hs_timestamp: Date.now().toString(),
|
|
hs_meeting_title: event.title,
|
|
hs_meeting_body: this.getHubspotMeetingBody(event),
|
|
hs_meeting_location: getLocation({
|
|
videoCallData: event.videoCallData,
|
|
additionalInformation: event.additionalInformation,
|
|
location: event.location,
|
|
uid: event.uid,
|
|
}),
|
|
hs_meeting_start_time: new Date(event.startTime).toISOString(),
|
|
hs_meeting_end_time: new Date(event.endTime).toISOString(),
|
|
hs_meeting_outcome: "SCHEDULED",
|
|
...writeToMeetingRecord,
|
|
};
|
|
|
|
if (hubspotOwnerId) {
|
|
properties.hubspot_owner_id = hubspotOwnerId;
|
|
this.log.debug("hubspot:meeting:setting_owner", { hubspotOwnerId });
|
|
}
|
|
|
|
const simplePublicObjectInput: SimplePublicObjectInput = {
|
|
properties,
|
|
};
|
|
|
|
return this.hubspotClient.crm.objects.meetings.basicApi.create(simplePublicObjectInput);
|
|
};
|
|
|
|
private hubspotAssociate = async (meeting: SimplePublicObject, contacts: Array<{ id: string }>) => {
|
|
const batchInputPublicAssociation: BatchInputPublicAssociation = {
|
|
inputs: contacts.map((contact: { id: string }) => ({
|
|
_from: { id: meeting.id },
|
|
to: { id: contact.id },
|
|
type: "meeting_event_to_contact",
|
|
})),
|
|
};
|
|
return this.hubspotClient.crm.associations.batchApi.create(
|
|
"meetings",
|
|
"contacts",
|
|
batchInputPublicAssociation
|
|
);
|
|
};
|
|
|
|
private hubspotUpdateMeeting = async (uid: string, event: CalendarEvent) => {
|
|
const simplePublicObjectInput: SimplePublicObjectInput = {
|
|
properties: {
|
|
hs_timestamp: Date.now().toString(),
|
|
hs_meeting_title: event.title,
|
|
hs_meeting_body: this.getHubspotMeetingBody(event),
|
|
hs_meeting_location: getLocation({
|
|
videoCallData: event.videoCallData,
|
|
additionalInformation: event.additionalInformation,
|
|
location: event.location,
|
|
uid: event.uid,
|
|
}),
|
|
hs_meeting_start_time: new Date(event.startTime).toISOString(),
|
|
hs_meeting_end_time: new Date(event.endTime).toISOString(),
|
|
hs_meeting_outcome: "RESCHEDULED",
|
|
},
|
|
};
|
|
|
|
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
|
};
|
|
|
|
private hubspotCancelMeeting = async (uid: string) => {
|
|
const simplePublicObjectInput: SimplePublicObjectInput = {
|
|
properties: {
|
|
hs_meeting_outcome: "CANCELED",
|
|
},
|
|
};
|
|
|
|
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
|
};
|
|
|
|
private hubspotArchiveMeeting = async (uid: string) => {
|
|
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
|
};
|
|
|
|
private hubspotAuth = async (credential: CredentialPayload) => {
|
|
const appKeys = await getAppKeysFromSlug("hubspot");
|
|
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
|
|
if (typeof appKeys.client_secret === "string") this.client_secret = appKeys.client_secret;
|
|
if (!this.client_id) throw new HttpError({ statusCode: 400, message: "Hubspot client_id missing." });
|
|
if (!this.client_secret)
|
|
throw new HttpError({ statusCode: 400, message: "Hubspot client_secret missing." });
|
|
let currentToken = credential.key as unknown as HubspotToken;
|
|
|
|
const isTokenValid = (token: HubspotToken) =>
|
|
token && token.tokenType && token.accessToken && token.expiryDate && token.expiryDate > Date.now();
|
|
|
|
const refreshAccessToken = async (refreshToken: string) => {
|
|
try {
|
|
const hubspotRefreshToken: HubspotToken = await refreshOAuthTokens(
|
|
async () =>
|
|
await this.hubspotClient.oauth.tokensApi.createToken(
|
|
"refresh_token",
|
|
undefined,
|
|
`${WEBAPP_URL}/api/integrations/hubspot/callback`,
|
|
this.client_id,
|
|
this.client_secret,
|
|
refreshToken
|
|
),
|
|
"hubspot",
|
|
credential.userId
|
|
);
|
|
hubspotRefreshToken.expiryDate = Math.round(Date.now() + hubspotRefreshToken.expiresIn * 1000);
|
|
await prisma.credential.update({
|
|
where: {
|
|
id: credential.id,
|
|
},
|
|
data: {
|
|
key: hubspotRefreshToken as any,
|
|
},
|
|
});
|
|
|
|
this.hubspotClient.setAccessToken(hubspotRefreshToken.accessToken);
|
|
currentToken = { ...currentToken, ...hubspotRefreshToken };
|
|
} catch (e: unknown) {
|
|
this.log.error(e);
|
|
}
|
|
};
|
|
|
|
return {
|
|
getToken: async () => {
|
|
if (!isTokenValid(currentToken)) {
|
|
await refreshAccessToken(currentToken.refreshToken);
|
|
} else {
|
|
this.hubspotClient.setAccessToken(currentToken.accessToken);
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
async handleMeetingCreation(event: CalendarEvent, contacts: Contact[]) {
|
|
const contactIds: { id?: string }[] = contacts.map((contact) => ({ id: contact.id }));
|
|
|
|
const organizerEmail = event.organizer.email;
|
|
this.log.debug("hubspot:meeting:fetching_owner");
|
|
|
|
const hubspotOwnerId = await this.getHubspotOwnerIdFromEmail(organizerEmail);
|
|
|
|
const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId ?? undefined);
|
|
if (meetingEvent) {
|
|
this.log.debug("meeting:creation:ok", { meetingId: meetingEvent.id, hubspotOwnerId });
|
|
const associatedMeeting = await this.hubspotAssociate(meetingEvent, contactIds as any);
|
|
if (associatedMeeting) {
|
|
this.log.debug("association:creation:ok", { associatedMeeting });
|
|
|
|
const firstContact = contacts[0];
|
|
const appOptions = this.getAppOptions();
|
|
if (hubspotOwnerId && firstContact?.id && appOptions?.setOrganizerAsOwner) {
|
|
await this.setContactOwnerIfAllowed(
|
|
firstContact.id,
|
|
hubspotOwnerId,
|
|
appOptions?.overwriteContactOwner ?? false
|
|
);
|
|
}
|
|
|
|
return Promise.resolve({
|
|
uid: meetingEvent.id,
|
|
id: meetingEvent.id,
|
|
type: "hubspot_other_calendar",
|
|
password: "",
|
|
url: "",
|
|
additionalInfo: { contacts, associatedMeeting },
|
|
});
|
|
}
|
|
return Promise.reject("Something went wrong when associating the meeting and attendees in HubSpot");
|
|
}
|
|
this.log.debug("meeting:creation:notOk", { meetingEvent, event, contacts });
|
|
return Promise.reject("Something went wrong when creating a meeting in HubSpot");
|
|
}
|
|
|
|
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
return await this.handleMeetingCreation(event, contacts);
|
|
}
|
|
|
|
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
return await this.hubspotUpdateMeeting(uid, event);
|
|
}
|
|
|
|
async deleteEvent(uid: string, event: CalendarEvent): Promise<void> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
if (event?.hasOrganizerChanged) {
|
|
await this.hubspotArchiveMeeting(uid);
|
|
return;
|
|
}
|
|
await this.hubspotCancelMeeting(uid);
|
|
}
|
|
|
|
async getContacts({
|
|
emails,
|
|
includeOwner,
|
|
forRoundRobinSkip,
|
|
}: {
|
|
emails: string | string[];
|
|
includeOwner?: boolean;
|
|
forRoundRobinSkip?: boolean;
|
|
}): Promise<Contact[]> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
const emailArray = Array.isArray(emails) ? emails : [emails];
|
|
|
|
const shouldIncludeOwner = includeOwner || forRoundRobinSkip;
|
|
|
|
const skipDueToFreeEmail =
|
|
forRoundRobinSkip && (await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]));
|
|
|
|
const publicObjectSearchRequest: PublicObjectSearchRequest = {
|
|
filterGroups: emailArray.map((attendeeEmail) => ({
|
|
filters: [
|
|
{
|
|
value: attendeeEmail,
|
|
propertyName: "email",
|
|
operator: "EQ",
|
|
},
|
|
],
|
|
})),
|
|
sorts: ["hs_object_id"],
|
|
properties:
|
|
shouldIncludeOwner && !skipDueToFreeEmail
|
|
? ["hs_object_id", "email", "hubspot_owner_id"]
|
|
: ["hs_object_id", "email"],
|
|
limit: 10,
|
|
after: 0,
|
|
};
|
|
|
|
const contacts = await this.hubspotClient.crm.contacts.searchApi
|
|
.doSearch(publicObjectSearchRequest)
|
|
.then((apiResponse) => apiResponse.results);
|
|
|
|
if (shouldIncludeOwner && !skipDueToFreeEmail) {
|
|
return await Promise.all(
|
|
contacts.map(async (contact) => {
|
|
const ownerId = contact.properties.hubspot_owner_id;
|
|
let ownerEmail: string | undefined;
|
|
|
|
if (ownerId) {
|
|
ownerEmail = await this.getOwnerEmailFromId(ownerId);
|
|
}
|
|
|
|
return {
|
|
id: contact.id,
|
|
email: contact.properties.email,
|
|
ownerId,
|
|
ownerEmail,
|
|
recordType: "CONTACT",
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
return contacts.map((contact) => {
|
|
return {
|
|
id: contact.id,
|
|
email: contact.properties.email,
|
|
};
|
|
});
|
|
}
|
|
|
|
async createContacts(contactsToCreate: ContactCreateInput[]): Promise<Contact[]> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
const simplePublicObjectInputs = contactsToCreate.map((attendee) => {
|
|
const [firstname, lastname] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
|
const properties: Record<string, string> = {
|
|
firstname,
|
|
lastname,
|
|
email: attendee.email,
|
|
};
|
|
|
|
if (attendee.phone) {
|
|
properties.phone = attendee.phone;
|
|
}
|
|
|
|
return {
|
|
properties,
|
|
};
|
|
});
|
|
const createdContacts = await Promise.all(
|
|
simplePublicObjectInputs.map((contact) =>
|
|
this.hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => {
|
|
// If multiple events are created, subsequent events may fail due to
|
|
// contact was created by previous event creation, so we introduce a
|
|
// fallback taking advantage of the error message providing the contact id
|
|
if (error.body.message.includes("Contact already exists. Existing ID:")) {
|
|
const split = error.body.message.split("Contact already exists. Existing ID: ");
|
|
return { id: split[1], properties: contact.properties };
|
|
} else {
|
|
throw error;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
return createdContacts.map((contact) => {
|
|
return {
|
|
id: contact.id,
|
|
email: contact.properties.email,
|
|
};
|
|
});
|
|
}
|
|
|
|
getAppOptions() {
|
|
return this.appOptions;
|
|
}
|
|
|
|
async handleAttendeeNoShow() {
|
|
console.log("Not implemented");
|
|
}
|
|
|
|
private async getHubspotOwnerIdFromEmail(email: string): Promise<string | null> {
|
|
try {
|
|
const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage(
|
|
email,
|
|
undefined,
|
|
100,
|
|
false
|
|
);
|
|
if (ownersResponse.results && ownersResponse.results.length > 0) {
|
|
const owner = ownersResponse.results.find((o) => o.email?.toLowerCase() === email.toLowerCase());
|
|
return owner?.id ?? null;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
this.log.error("Error fetching HubSpot owner:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async setContactOwner(contactId: string, ownerId: string): Promise<void> {
|
|
try {
|
|
await this.hubspotClient.crm.contacts.basicApi.update(contactId, {
|
|
properties: { hubspot_owner_id: ownerId },
|
|
});
|
|
} catch (error) {
|
|
this.log.error("Error setting contact owner:", error);
|
|
}
|
|
}
|
|
|
|
private async getContactOwnerId(contactId: string): Promise<string | null> {
|
|
const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, ["hubspot_owner_id"]);
|
|
return contact.properties.hubspot_owner_id ?? null;
|
|
}
|
|
|
|
private async setContactOwnerIfAllowed(
|
|
contactId: string,
|
|
ownerId: string,
|
|
overwriteContactOwner: boolean
|
|
): Promise<void> {
|
|
if (overwriteContactOwner) {
|
|
await this.setContactOwner(contactId, ownerId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentOwnerId = await this.getContactOwnerId(contactId);
|
|
if (!currentOwnerId) {
|
|
await this.setContactOwner(contactId, ownerId);
|
|
}
|
|
} catch (error) {
|
|
this.log.error("Error fetching contact owner, skipping owner update:", error);
|
|
}
|
|
}
|
|
|
|
private async getOwnerEmailFromId(ownerId: string): Promise<string | undefined> {
|
|
try {
|
|
const owner = await this.hubspotClient.crm.owners.ownersApi.getById(parseInt(ownerId, 10));
|
|
return owner.email ?? undefined;
|
|
} catch (error) {
|
|
this.log.error("Error fetching owner by ID:", error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async shouldSkipAttendeeIfFreeEmailDomain(attendeeEmail: string): Promise<boolean> {
|
|
const appOptions = this.getAppOptions();
|
|
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
|
|
|
|
const response = await checkIfFreeEmailDomain({ email: attendeeEmail });
|
|
return response;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function that creates a HubSpot CRM service instance.
|
|
* This is exported instead of the class to prevent SDK types (like hubspot.Client)
|
|
* from leaking into the emitted .d.ts file, which would cause TypeScript to load
|
|
* all 1,800+ HubSpot SDK declaration files when type-checking dependent packages.
|
|
*/
|
|
export default function BuildCrmService(
|
|
credential: CredentialPayload,
|
|
appOptions?: Record<string, unknown>
|
|
): CRM {
|
|
return new HubspotCalendarService(credential, appOptions);
|
|
}
|