diff --git a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx index c6d37c7427a990..1e844706b86404 100644 --- a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx @@ -30,6 +30,9 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ const createEventOnLeadCheckForContact = getAppData("createEventOnLeadCheckForContact") ?? false; const onBookingChangeRecordOwner = getAppData("onBookingChangeRecordOwner") ?? false; const onBookingChangeRecordOwnerName = getAppData("onBookingChangeRecordOwnerName") ?? []; + const sendNoShowAttendeeData = getAppData("sendNoShowAttendeeData") ?? false; + const sendNoShowAttendeeDataField = getAppData("sendNoShowAttendeeDataField") ?? ""; + const { t } = useLocale(); const recordOptions = [ @@ -278,6 +281,25 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ ) : null} + +
+ { + setAppData("sendNoShowAttendeeData", checked); + }} + /> + {sendNoShowAttendeeData ? ( +
+

Field name to check (must be checkbox data type)

+ setAppData("sendNoShowAttendeeDataField", e.target.value)} + /> +
+ ) : null} +
); diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index f6ec791f6136a8..a9be986419b685 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -15,6 +15,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; +import { default as appMeta } from "../config.json"; import { SalesforceRecordEnum } from "./recordEnum"; type ExtendedTokenResponse = TokenResponse & { @@ -502,6 +503,80 @@ export default class SalesforceCRMService implements CRM { return createdContacts; } + async handleAttendeeNoShow(bookingUid: string, attendees: { email: string; noShow: boolean }[]) { + const appOptions = this.getAppOptions(); + const { sendNoShowAttendeeData, sendNoShowAttendeeDataField } = appOptions; + const conn = await this.conn; + // Check that no show is enabled + if (!sendNoShowAttendeeData && !sendNoShowAttendeeDataField) { + this.log.warn(`No show settings not set for bookingUid ${bookingUid}`); + return; + } + // Get all Salesforce events associated with the booking + const salesforceEvents = await prisma.bookingReference.findMany({ + where: { + type: appMeta.type, + booking: { + uid: bookingUid, + }, + }, + }); + + const salesforceEntity = await conn.describe("Event"); + const fields = salesforceEntity.fields; + const noShowField = fields.find((field) => field.name === sendNoShowAttendeeDataField); + + if (!noShowField || (!noShowField.type as unknown as string) !== "boolean") { + this.log.warn( + `No show field on Salesforce doesn't exist or is not of type boolean for bookingUid ${bookingUid}` + ); + return; + } + + for (const event of salesforceEvents) { + const salesforceEvent = (await conn.query(`SELECT WhoId FROM Event WHERE Id = '${event.uid}'`)) as { + records: { WhoId: string }[]; + }; + + let salesforceAttendeeEmail: string | undefined = undefined; + // Figure out if the attendee is a contact or lead + const contactQuery = (await conn.query( + `SELECT Email FROM Contact WHERE Id = '${salesforceEvent.records[0].WhoId}'` + )) as { records: { Email: string }[] }; + const leadQuery = (await conn.query( + `SELECT Email FROM Lead WHERE Id = '${salesforceEvent.records[0].WhoId}'` + )) as { records: { Email: string }[] }; + + // Prioritize contacts over leads + if (contactQuery.records.length > 0) { + salesforceAttendeeEmail = contactQuery.records[0].Email; + } else if (leadQuery.records.length > 0) { + salesforceAttendeeEmail = leadQuery.records[0].Email; + } else { + this.log.warn( + `Could not find attendee for bookingUid ${bookingUid} and salesforce event id ${event.uid}` + ); + } + + if (salesforceAttendeeEmail) { + // Find the attendee no show data + const noShowData = attendees.find((attendee) => attendee.email === salesforceAttendeeEmail); + + if (!noShowData) { + this.log.warn( + `No show data could not be found for ${salesforceAttendeeEmail} and bookingUid ${bookingUid}` + ); + } else { + // Update the event with the no show data + await conn.sobject("Event").update({ + Id: event.uid, + [sendNoShowAttendeeDataField]: noShowData.noShow, + }); + } + } + } + } + private getExistingIdFromDuplicateError(error: any): string | null { if (error.duplicateResult && error.duplicateResult.matchResults) { for (const matchResult of error.duplicateResult.matchResults) { diff --git a/packages/app-store/salesforce/zod.ts b/packages/app-store/salesforce/zod.ts index ad3eb41e12e0db..a2c7e016ef170d 100644 --- a/packages/app-store/salesforce/zod.ts +++ b/packages/app-store/salesforce/zod.ts @@ -18,6 +18,8 @@ export const appDataSchema = eventTypeAppCardZod.extend({ createEventOnLeadCheckForContact: z.boolean().optional(), onBookingChangeRecordOwner: z.boolean().optional(), onBookingChangeRecordOwnerName: z.string().optional(), + sendNoShowAttendeeData: z.boolean().optional(), + sendNoShowAttendeeDataField: z.string().optional(), }); export const appKeysSchema = z.object({ diff --git a/packages/core/crmManager/crmManager.ts b/packages/core/crmManager/crmManager.ts index 835142effe71d6..fd530b654fc590 100644 --- a/packages/core/crmManager/crmManager.ts +++ b/packages/core/crmManager/crmManager.ts @@ -72,4 +72,11 @@ export default class CrmManager { const createdContacts = (await crmService?.createContacts(contactsToCreate, organizerEmail)) || []; return createdContacts; } + + public async handleAttendeeNoShow(bookingUid: string, attendees: { email: string; noShow: boolean }[]) { + const crmService = await this.getCrmService(this.credential); + if (crmService?.handleAttendeeNoShow) { + await crmService.handleAttendeeNoShow(bookingUid, attendees); + } + } } diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index becf9724cd680c..784d3a70be8f14 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -10,6 +10,10 @@ import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/client"; import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; +import handleSendingAttendeeNoShowDataToApps from "./noShow/handleSendingAttendeeNoShowDataToApps"; + +export type NoShowAttendees = { email: string; noShow: boolean }[]; + const buildResultPayload = async ( bookingUid: string, attendeeEmails: string[], @@ -41,7 +45,7 @@ const logFailedResults = (results: PromiseSettledResult[]) => { }; class ResponsePayload { - attendees: { email: string; noShow: boolean }[]; + attendees: NoShowAttendees; noShowHost: boolean; message: string; @@ -101,6 +105,8 @@ const handleMarkNoShow = async ({ responsePayload.setAttendees(payload.attendees); responsePayload.setMessage(payload.message); + + await handleSendingAttendeeNoShowDataToApps(bookingUid, attendees); } if (noShowHost) { diff --git a/packages/features/noShow/handleSendingAttendeeNoShowDataToApps.ts b/packages/features/noShow/handleSendingAttendeeNoShowDataToApps.ts new file mode 100644 index 00000000000000..f46de7b0416ccc --- /dev/null +++ b/packages/features/noShow/handleSendingAttendeeNoShowDataToApps.ts @@ -0,0 +1,85 @@ +import type { z } from "zod"; + +import type { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import CrmManager from "@calcom/core/crmManager/crmManager"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import type { NoShowAttendees } from "../handleMarkNoShow"; +import { noShowEnabledApps } from "./noShowEnabledApps"; + +const log = logger.getSubLogger({ prefix: ["handleSendingNoShowDataToApps"] }); + +export default async function handleSendingAttendeeNoShowDataToApps( + bookingUid: string, + attendees: NoShowAttendees +) { + // Get event type metadata + const eventTypeQuery = await prisma.booking.findFirst({ + where: { + uid: bookingUid, + }, + select: { + eventType: { + select: { + metadata: true, + }, + }, + }, + }); + if (!eventTypeQuery || !eventTypeQuery?.eventType?.metadata) { + log.warn(`For no show, could not find eventType for bookingUid ${bookingUid}`); + return; + } + + const eventTypeMetadataParse = EventTypeMetaDataSchema.safeParse(eventTypeQuery?.eventType?.metadata); + if (!eventTypeMetadataParse.success) { + log.error(`Malformed event type metadata for bookingUid ${bookingUid}`); + return; + } + const eventTypeAppMetadata = eventTypeMetadataParse.data?.apps; + + for (const slug in eventTypeAppMetadata) { + if (noShowEnabledApps.includes(slug)) { + const app = eventTypeAppMetadata[slug as keyof typeof eventTypeAppMetadata]; + + const appCategory = app.appCategories[0]; + + if (appCategory === "crm") { + await handleCRMNoShow(bookingUid, app, attendees); + } + } + } + + return; +} + +async function handleCRMNoShow( + bookingUid: string, + appData: z.infer, + attendees: NoShowAttendees +) { + // Handle checking if no she in CrmService + + const credential = await prisma.credential.findFirst({ + where: { + id: appData.credentialId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (!credential) { + log.warn(`CRM credential not found for bookingUid ${bookingUid}`); + return; + } + + const crm = new CrmManager(credential, appData); + await crm.handleAttendeeNoShow(bookingUid, attendees); +} diff --git a/packages/features/noShow/noShowEnabledApps.ts b/packages/features/noShow/noShowEnabledApps.ts new file mode 100644 index 00000000000000..75bbb121f8e9c4 --- /dev/null +++ b/packages/features/noShow/noShowEnabledApps.ts @@ -0,0 +1,2 @@ +/** Slugs of apps that have the option to send no show data to */ +export const noShowEnabledApps = ["salesforce"]; diff --git a/packages/types/CrmService.d.ts b/packages/types/CrmService.d.ts index 029bc59a600d13..a4275ca2bbbec2 100644 --- a/packages/types/CrmService.d.ts +++ b/packages/types/CrmService.d.ts @@ -39,4 +39,8 @@ export interface CRM { }) => Promise; createContacts: (contactsToCreate: ContactCreateInput[], organizerEmail?: string) => Promise; getAppOptions: () => any; + handleAttendeeNoShow?: ( + bookingUid: string, + attendees: { email: string; noShow: boolean }[] + ) => Promise; }