Files
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
96bec9f7b9 refactor: Move repositories from @calcom/lib to @calcom/features domain folders (#27570)
* 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>
2026-02-09 08:41:04 -03:00

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);
}