feat: Dub App integration (#21321)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2025-05-22 09:18:45 -07:00
committed by GitHub
co-authored by Peer Richelsen sean-brydon
parent 324ac691eb
commit 357c82129c
35 changed files with 518 additions and 2 deletions
+51
View File
@@ -0,0 +1,51 @@
import logger from "@calcom/lib/logger";
import type { AnalyticsService, AnalyticsServiceClass } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
import appStore from "..";
const log = logger.getSubLogger({ prefix: ["AnalyticsManager"] });
interface AnalyticsApp {
lib: {
AnalyticsService: AnalyticsServiceClass;
};
}
const isAnalyticsService = (x: unknown): x is AnalyticsApp =>
!!x &&
typeof x === "object" &&
"lib" in x &&
typeof x.lib === "object" &&
!!x.lib &&
"AnalyticsService" in x.lib;
export const getAnalyticsService = async ({
credential,
}: {
credential: CredentialPayload;
}): Promise<AnalyticsService | null> => {
if (!credential || !credential.key) return null;
const { type: analyticsType } = credential;
const analyticsName = analyticsType.split("_")[0];
const analyticsAppImportFn = appStore[analyticsName as keyof typeof appStore];
if (!analyticsAppImportFn) {
log.warn(`analytics app not implemented`);
return null;
}
const analyticsApp = await analyticsAppImportFn();
if (!isAnalyticsService(analyticsApp)) {
log.warn(`Analytics is not implemented`);
return null;
}
const AnalyticsService = analyticsApp.lib.AnalyticsService;
log.info("Got analyticsApp", AnalyticsService);
return new AnalyticsService(credential);
};
@@ -21,6 +21,7 @@ import deel_config_json from "./deel/config.json";
import demodesk_config_json from "./demodesk/config.json";
import dialpad_config_json from "./dialpad/config.json";
import discord_config_json from "./discord/config.json";
import dub_config_json from "./dub/config.json";
import eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json";
import elevenlabs_config_json from "./elevenlabs/config.json";
@@ -127,6 +128,7 @@ export const appStoreMetadata = {
demodesk: demodesk_config_json,
dialpad: dialpad_config_json,
discord: discord_config_json,
dub: dub_config_json,
eightxeight: eightxeight_config_json,
"element-call": element_call_config_json,
elevenlabs: elevenlabs_config_json,
@@ -21,6 +21,7 @@ export const apiHandlers = {
demodesk: import("./demodesk/api"),
dialpad: import("./dialpad/api"),
discord: import("./discord/api"),
dub: import("./dub/api"),
eightxeight: import("./eightxeight/api"),
"element-call": import("./element-call/api"),
elevenlabs: import("./elevenlabs/api"),
+16
View File
@@ -0,0 +1,16 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
- 4.jpeg
---
{DESCRIPTION}
## How to Get Started
1. **Log in** to your [Dub.co](https://dub.co) account by installing the app.
2. **Set up the [Client SDK](https://dub.co/docs/sdks/client-side/introduction)** on your website:
- Add `app.cal.com` to your [Outbound Domains](https://dub.co/docs/sdks/client-side/features/cross-domain-tracking).
3. Once a **booking event** occurs, your tracking data will be captured automatically.
+37
View File
@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import { dubAppKeysSchema, scopeString } from "../lib/utils";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const loggedInUser = req.session?.user;
if (!loggedInUser) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
// Ideally this should never happen, as email is there in session user but typings aren't accurate it seems
// TODO: So, confirm and later fix the typings
if (!loggedInUser.email) {
throw new HttpError({ statusCode: 400, message: "Session user must have an email" });
}
const { client_id, redirect_uris } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
const url = new URL("https://app.dub.co/oauth/authorize");
url.searchParams.append("client_id", client_id);
url.searchParams.append("redirect_uri", redirect_uris);
url.searchParams.append("response_type", "code");
url.searchParams.append("scope", scopeString);
const oauthUrl = url.toString();
return res.status(200).json({ url: oauthUrl });
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+76
View File
@@ -0,0 +1,76 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { dubAppKeysSchema } from "../lib/utils";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);
if (typeof code !== "string") {
if (state?.onErrorReturnTo || state?.returnTo) {
res.redirect(
getSafeRedirectUrl(state.onErrorReturnTo) ??
getSafeRedirectUrl(state?.returnTo) ??
`${WEBAPP_URL}/apps/installed`
);
return;
}
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
}
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const { client_id, redirect_uris, client_secret } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
const codeExchangeUrl = `https://api.dub.co/oauth/token`;
const result = await fetch(codeExchangeUrl, {
method: "POST",
body: new URLSearchParams({
code,
client_id,
redirect_uri: redirect_uris,
client_secret,
grant_type: "authorization_code",
}).toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (result.status !== 200) {
let errorMessage = "Something wrong with Dub Api";
try {
const responseBody = await result.json();
if (typeof responseBody?.error?.message === "string") {
errorMessage = responseBody.error.message;
}
} catch (err) {
return;
}
return res.status(400).json({ message: errorMessage });
}
const responseBody = await result.json();
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
await createOAuthAppCredential({ appId: "dub", type: "dub" }, responseBody, req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "analytics", slug: "dub" })
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
+16
View File
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Dub",
"slug": "dub",
"type": "dub_analytics",
"logo": "icon.svg",
"url": "https://dub.co",
"variant": "analytics",
"categories": ["analytics"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic"
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,108 @@
import { Dub } from "dub-package";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { DubOAuthToken } from "./type";
export default class DubService implements AnalyticsService {
private dubClient?: Dub;
private client_id = "";
private client_secret = "";
private log = logger.getSubLogger({ prefix: ["[[lib]] dub"] });
private credential: CredentialPayload;
constructor(credential: CredentialPayload) {
this.credential = credential;
this.client_id = "";
this.client_secret = "";
}
private async initClient() {
const appKeys = await getAppKeysFromSlug("dub");
const { client_id, client_secret } = appKeys;
if (!client_id || !client_secret) {
this.log.error("Dub.co app keys missing!");
return;
}
this.client_id = client_id as string;
this.client_secret = client_secret as string;
let token = this.credential.key as unknown as DubOAuthToken | undefined;
if (!token) return;
const isTokenExpired = (token: DubOAuthToken) => {
if (!token || !token.access_token) return true;
if (token.expiry_date) {
return token.expiry_date < Date.now();
}
return false;
};
if (isTokenExpired(token)) {
token = await this.refreshAccessToken(token.refresh_token);
if (!token) return;
}
this.dubClient = new Dub({ token: token.access_token });
}
private async refreshAccessToken(refreshToken: string): Promise<DubOAuthToken | undefined> {
try {
if (!refreshToken) return;
const newToken: DubOAuthToken = await refreshOAuthTokens(
async () => {
const response = await fetch(`https://api.dub.co/oauth/token`, {
method: "POST",
body: new URLSearchParams({
client_id: this.client_id,
client_secret: this.client_secret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}).toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return await response.json();
},
"dub",
this.credential.userId
);
newToken.expiry_date = Date.now() + newToken.expires_in * 1000;
await CredentialRepository.updateCredentialById({
id: this.credential.id,
data: { key: newToken as any },
});
return newToken;
} catch (err) {
this.log.error(err);
}
}
async sendEvent({ name, email, eventName, id, externalId }: SendEventProps): Promise<void> {
await this.initClient();
if (!this.dubClient) return;
await this.dubClient.track.lead({
clickId: id,
customerName: name,
customerEmail: email,
externalId: externalId ?? email,
eventName: eventName ?? "Cal.com lead",
});
}
}
+1
View File
@@ -0,0 +1 @@
export { default as AnalyticsService } from "./AnalyticsService";
+8
View File
@@ -0,0 +1,8 @@
export interface DubOAuthToken {
access_token: string;
refresh_token: string;
token_type: "Bearer";
expires_in: number;
expiry_date?: number;
scope: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
export const dubAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
redirect_uris: z.string(),
});
const dubScope = ["workspaces.read"];
export const scopeString = dubScope.join(",");
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/dub",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"dub-package": "npm:dub@^0.61.12"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

@@ -0,0 +1 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 387 B

+1
View File
@@ -0,0 +1 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 387 B

+1
View File
@@ -4,6 +4,7 @@ const appStore = {
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
closecom: createCachedImport(() => import("./closecom")),
dailyvideo: createCachedImport(() => import("./dailyvideo")),
dub: createCachedImport(() => import("./dub")),
googlecalendar: createCachedImport(() => import("./googlecalendar")),
googlevideo: createCachedImport(() => import("./googlevideo")),
hubspot: createCachedImport(() => import("./hubspot")),
+1 -1
View File
@@ -1,4 +1,4 @@
import { Dub } from "dub";
import { Dub } from "dub-package";
export const dub = new Dub({
token: process.env.DUB_API_KEY,
@@ -1053,7 +1053,7 @@ export const getOptions = ({
dub.track.lead({
clickId,
eventName: "Sign Up",
customerId: user.id.toString(),
externalId: user.id.toString(),
customerName: user.name,
customerEmail: user.email,
customerAvatar: user.image,
@@ -60,6 +60,7 @@ export const mapBookingToMutationInput = ({
const _isDryRun = isBookingDryRun(searchParams);
const _cacheParam = searchParams?.get("cal.cache");
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
const dub_id = searchParams?.get("dub_id");
return {
...values,
@@ -91,6 +92,7 @@ export const mapBookingToMutationInput = ({
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
_isDryRun,
_shouldServeCache,
dub_id,
};
};
@@ -44,6 +44,7 @@ import {
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import EventManager from "@calcom/lib/EventManager";
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import {
@@ -2160,6 +2161,18 @@ async function handler(
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
}
if (!isDryRun) {
await handleAnalyticsEvents({
credentials: allCredentials,
rawBookingData,
bookingInfo: {
name: fullName,
email: bookerEmail,
eventName: "Cal.com lead",
},
});
}
// TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
const bookingResponse = {
+1
View File
@@ -20,6 +20,7 @@ type TaskPayloads = {
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
sendWorkflowEmails: z.infer<typeof import("./tasks/sendWorkflowEmails").ZSendWorkflowEmailsSchema>;
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
sendAnalyticsEvent: z.infer<typeof import("./tasks/analytics/schema").sendAnalyticsEventSchema>;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string) => Promise<void>;
@@ -0,0 +1,12 @@
import z from "zod";
export const sendAnalyticsEventSchema = z.object({
credentialId: z.number(),
info: z.object({
name: z.string(),
email: z.string(),
id: z.string(),
eventName: z.string(),
externalId: z.string().optional(),
}),
});
@@ -0,0 +1,37 @@
import AnalyticsManager from "@calcom/lib/analyticsManager/analyticsManager";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { sendAnalyticsEventSchema } from "./schema";
const log = logger.getSubLogger({ prefix: [`[[tasker] sendAnalyticsEvent]`] });
export async function sendAnalyticsEvent(payload: string): Promise<void> {
try {
const parsedPayload = sendAnalyticsEventSchema.safeParse(JSON.parse(payload));
if (!parsedPayload.success) {
throw new Error(`malformed payload in sendAnalyticsEvent: ${parsedPayload.error}`);
}
const { credentialId, info } = parsedPayload.data;
const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: credentialId });
if (!credential) {
throw new Error("Invalid credential");
}
const manager = new AnalyticsManager(credential);
if (!manager) return;
await manager.sendEvent(info);
} catch (err) {
log.error(
`[Will retry] Error creating analytics event: error: ${safeStringify(err)} payload: ${safeStringify({
payload,
})}`
);
// Intentional rethrow to trigger retry
throw err;
}
}
+2
View File
@@ -24,6 +24,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent),
sendWorkflowEmails: () => import("./sendWorkflowEmails").then((module) => module.sendWorkflowEmails),
scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody),
sendAnalyticsEvent: () =>
import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent),
};
export const tasksConfig = {
@@ -0,0 +1,33 @@
import { getAnalyticsService } from "@calcom/app-store/_utils/getAnalytics";
import logger from "@calcom/lib/logger";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["AnalyticsManager"] });
export default class AnalyticsManager {
analyticsService: AnalyticsService | null | undefined = null;
credential: CredentialPayload;
constructor(credential: CredentialPayload) {
this.credential = credential;
}
private async getAnalyticsService(credential: CredentialPayload) {
if (this.analyticsService) return this.analyticsService;
const analyticsService = await getAnalyticsService({ credential });
this.analyticsService = analyticsService;
if (!this.analyticsService) {
log.error("Analytics service initialization failed");
}
return analyticsService;
}
public async sendEvent(props: SendEventProps) {
const analyticsService = await this.getAnalyticsService(this.credential);
if (!analyticsService) return;
return await analyticsService.sendEvent(props);
}
}
@@ -0,0 +1,38 @@
import tasker from "@calcom/features/tasker";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
interface HandleAnalyticsEventsProps {
credentials: CredentialForCalendarService[];
rawBookingData: Record<string, any>;
bookingInfo: {
email: string;
name: string;
eventName: string;
};
}
export const handleAnalyticsEvents = async ({
credentials,
rawBookingData,
bookingInfo,
}: HandleAnalyticsEventsProps) => {
const { dub_id } = await rawBookingData;
if (!dub_id || typeof dub_id !== "string") return;
const dubCredential = credentials.find((cred) => cred.appId === "dub");
if (!dubCredential) return;
try {
await tasker.create("sendAnalyticsEvent", {
credentialId: dubCredential.id,
info: {
id: dub_id,
...bookingInfo,
},
});
} catch (err) {
console.error("Error sending dub lead: ", err);
}
};
@@ -12,6 +12,14 @@ type CredentialCreateInput = {
delegationCredentialId?: string | null;
};
type CredentialUpdateInput = {
type?: string;
key?: any;
userId?: number;
appId?: string;
delegationCredentialId?: string | null;
};
export class CredentialRepository {
static async create(data: CredentialCreateInput) {
const credential = await prisma.credential.create({ data: { ...data } });
@@ -64,6 +72,13 @@ export class CredentialRepository {
await prisma.credential.delete({ where: { id } });
}
static async updateCredentialById({ id, data }: { id: number; data: CredentialUpdateInput }) {
await prisma.credential.update({
where: { id },
data,
});
}
static async deleteAllByDelegationCredentialId({
delegationCredentialId,
}: {
+1
View File
@@ -46,6 +46,7 @@ export const bookingCreateBodySchema = z.object({
utm_term: z.string().optional(),
utm_content: z.string().optional(),
}).optional(),
dub_id: z.string().nullish()
});
export type BookingCreateBody = z.input<typeof bookingCreateBodySchema>;
+13
View File
@@ -0,0 +1,13 @@
export interface SendEventProps {
name: string;
email: string;
id: string;
eventName: string;
externalId?: string;
}
export interface AnalyticsService {
sendEvent(props: SendEventProps): Promise<void>;
}
export type AnalyticsServiceClass = Class<AnalyticsService>;