Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4468,5 +4468,15 @@
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
"zoominfo_enriched_data": "Enriched Attendee Data",
"zoominfo_job_function": "Job Function",
"zoominfo_management_level": "Management Level",
"zoominfo_company_info": "Company Information",
"zoominfo_industry": "Industry",
"zoominfo_employees": "Employees",
"zoominfo_revenue": "Revenue",
"view_profile": "View Profile",
"company": "Company",
"website": "Website",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
2 changes: 2 additions & 0 deletions packages/app-store/apps.keys-schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
import { appKeysSchema as zohocalendar_zod_ts } from "./zohocalendar/zod";
import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appKeysSchema as zoominfo_zod_ts } from "./zoominfo/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
Expand Down Expand Up @@ -102,5 +103,6 @@ export const appKeysSchemas = {
"zoho-bigin": zoho_bigin_zod_ts,
zohocalendar: zohocalendar_zod_ts,
zohocrm: zohocrm_zod_ts,
zoominfo: zoominfo_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};
2 changes: 2 additions & 0 deletions packages/app-store/apps.metadata.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import zapier_config_json from "./zapier/config.json";
import zoho_bigin_config_json from "./zoho-bigin/config.json";
import zohocalendar_config_json from "./zohocalendar/config.json";
import zohocrm_config_json from "./zohocrm/config.json";
import zoominfo_config_json from "./zoominfo/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
alby: alby_config_json,
Expand Down Expand Up @@ -220,5 +221,6 @@ export const appStoreMetadata = {
"zoho-bigin": zoho_bigin_config_json,
zohocalendar: zohocalendar_config_json,
zohocrm: zohocrm_config_json,
zoominfo: zoominfo_config_json,
zoomvideo: zoomvideo__metadata_ts,
};
2 changes: 2 additions & 0 deletions packages/app-store/apps.schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
import { appDataSchema as zohocalendar_zod_ts } from "./zohocalendar/zod";
import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appDataSchema as zoominfo_zod_ts } from "./zoominfo/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
Expand Down Expand Up @@ -102,5 +103,6 @@ export const appDataSchemas = {
"zoho-bigin": zoho_bigin_zod_ts,
zohocalendar: zohocalendar_zod_ts,
zohocrm: zohocrm_zod_ts,
zoominfo: zoominfo_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};
1 change: 1 addition & 0 deletions packages/app-store/apps.server.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,6 @@ export const apiHandlers = {
"zoho-bigin": import("./zoho-bigin/api"),
zohocalendar: import("./zohocalendar/api"),
zohocrm: import("./zohocrm/api"),
zoominfo: import("./zoominfo/api"),
zoomvideo: import("./zoomvideo/api"),
};
10 changes: 10 additions & 0 deletions packages/app-store/zoominfo/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ZoomInfo is the leading B2B intelligence platform that provides comprehensive data on companies and contacts. This integration automatically enriches attendee data for every booking with valuable business intelligence.

When installed and enabled on an event type, ZoomInfo will enrich attendee information when bookings are created. The enriched data includes:

- Contact details (name, email, phone, job title)
- Company information (name, industry, revenue, employee count)
- Professional context (job function, management level)
- Social profiles (LinkedIn URL)

This enriched data is displayed only to the host on the bookings page and booking success page, helping you prepare for meetings with valuable context about your attendees.
28 changes: 28 additions & 0 deletions packages/app-store/zoominfo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# ZoomInfo Integration

This integration enriches attendee data with ZoomInfo's B2B intelligence for every booking.

## Setup

1. Create a ZoomInfo developer account at https://developers.zoominfo.com/
2. Create an OAuth application in the ZoomInfo Developer Portal
3. Configure the OAuth redirect URI to: `<baseUrl>/api/integrations/zoominfo/callback`
4. Add your ZoomInfo credentials in the Cal.com admin settings:
- `ZOOMINFO_CLIENT_ID`: Your ZoomInfo OAuth client ID
- `ZOOMINFO_CLIENT_SECRET`: Your ZoomInfo OAuth client secret

## Features

- Automatic attendee enrichment on booking creation
- Contact information (name, email, phone, job title)
- Company details (name, industry, revenue, employee count)
- Professional context (job function, management level)
- LinkedIn profile URL

## Privacy

Enriched data is only visible to the host of the booking, not to attendees. This ensures that attendees' privacy is respected while providing valuable context to hosts.

## API Documentation

For more information about the ZoomInfo API, visit: https://docs.zoominfo.com/
20 changes: 20 additions & 0 deletions packages/app-store/zoominfo/api/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import type { NextApiRequest, NextApiResponse } from "next";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });

const appKeys = await getAppKeysFromSlug("zoominfo");
let clientId = "";
if (typeof appKeys.client_id === "string") clientId = appKeys.client_id;
if (!clientId) return res.status(400).json({ message: "ZoomInfo client id missing." });

const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoominfo/callback`;
const state = encodeOAuthState(req);

const url = `https://api.zoominfo.com/oauth/authorize?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&state=${state}`;

res.status(200).json({ url });
}
68 changes: 68 additions & 0 deletions packages/app-store/zoominfo/api/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import type { NextApiRequest, NextApiResponse } from "next";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";

export interface ZoomInfoToken {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
expiryDate?: number;
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);

if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}

if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}

let clientId = "";
let clientSecret = "";
const appKeys = await getAppKeysFromSlug("zoominfo");
if (typeof appKeys.client_id === "string") clientId = appKeys.client_id;
if (typeof appKeys.client_secret === "string") clientSecret = appKeys.client_secret;
if (!clientId) return res.status(400).json({ message: "ZoomInfo client id missing." });
if (!clientSecret) return res.status(400).json({ message: "ZoomInfo client secret missing." });

const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoominfo/callback`;

const tokenResponse = await fetch("https://api.zoominfo.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code as string,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
}),
});

if (!tokenResponse.ok) {
const error = await tokenResponse.text();
return res.status(400).json({ message: `Failed to get ZoomInfo token: ${error}` });
}

const zoominfoToken: ZoomInfoToken = await tokenResponse.json();
zoominfoToken.expiryDate = Math.round(Date.now() + zoominfoToken.expires_in * 1000);

await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, zoominfoToken, req);

res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "zoominfo" })
);
}
110 changes: 110 additions & 0 deletions packages/app-store/zoominfo/api/enrich.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";

import ZoomInfoService from "../lib/ZoomInfoService";
import type { ZoomInfoEnrichedData } from "../zod";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}

if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}

const { bookingUid } = req.body;

if (!bookingUid || typeof bookingUid !== "string") {
return res.status(400).json({ message: "bookingUid is required" });
}

const booking = await prisma.booking.findUnique({
where: { uid: bookingUid },
select: {
id: true,
uid: true,
userId: true,
metadata: true,
attendees: {
select: {
email: true,
name: true,
},
},
eventType: {
select: {
userId: true,
teamId: true,
},
},
},
});

if (!booking) {
return res.status(404).json({ message: "Booking not found" });
}

const isHost = booking.userId === req.session.user.id;
if (!isHost) {
return res.status(403).json({ message: "Only the host can enrich attendee data" });
}

const credential = await prisma.credential.findFirst({
where: {
userId: req.session.user.id,
type: "zoominfo_other",
},
select: {
id: true,
type: true,
key: true,
userId: true,
teamId: true,
appId: true,
invalid: true,
},
});

if (!credential) {
return res
.status(400)
.json({ message: "ZoomInfo is not connected. Please install the ZoomInfo app first." });
}

const zoominfoService = new ZoomInfoService({
credentialId: credential.id,
credentialKey: credential.key,
userId: credential.userId,
});

const attendeeEmails = booking.attendees.map((a) => a.email);
const enrichedDataMap = await zoominfoService.enrichContacts(attendeeEmails);

const enrichedData: Record<string, ZoomInfoEnrichedData> = {};
enrichedDataMap.forEach((data, email) => {
enrichedData[email] = data;
});

const existingMetadata =
typeof booking.metadata === "object" && booking.metadata !== null
? (booking.metadata as Prisma.JsonObject)
: {};

await prisma.booking.update({
where: { uid: bookingUid },
data: {
metadata: {
...existingMetadata,
zoominfoEnrichedData: enrichedData as Prisma.JsonObject,
},
},
});

return res.status(200).json({
success: true,
enrichedCount: enrichedDataMap.size,
enrichedData,
});
}
3 changes: 3 additions & 0 deletions packages/app-store/zoominfo/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as enrich } from "./enrich";
Loading
Loading