diff --git a/packages/app-store/_utils/getAnalytics.ts b/packages/app-store/_utils/getAnalytics.ts new file mode 100644 index 0000000000..e0a68180f6 --- /dev/null +++ b/packages/app-store/_utils/getAnalytics.ts @@ -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 => { + 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); +}; diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 74cb40a888..9421d12428 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -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, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index c4b8753613..d3d54d7c89 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -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"), diff --git a/packages/app-store/dub/DESCRIPTION.md b/packages/app-store/dub/DESCRIPTION.md new file mode 100644 index 0000000000..d9020f0e84 --- /dev/null +++ b/packages/app-store/dub/DESCRIPTION.md @@ -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. diff --git a/packages/app-store/dub/api/add.ts b/packages/app-store/dub/api/add.ts new file mode 100644 index 0000000000..e91b9a0281 --- /dev/null +++ b/packages/app-store/dub/api/add.ts @@ -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) }), +}); diff --git a/packages/app-store/dub/api/callback.ts b/packages/app-store/dub/api/callback.ts new file mode 100644 index 0000000000..3da4c277f5 --- /dev/null +++ b/packages/app-store/dub/api/callback.ts @@ -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" }) + ); +} diff --git a/packages/app-store/dub/api/index.ts b/packages/app-store/dub/api/index.ts new file mode 100644 index 0000000000..eb12c1b4ed --- /dev/null +++ b/packages/app-store/dub/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/dub/components/.gitkeep b/packages/app-store/dub/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/dub/config.json b/packages/app-store/dub/config.json new file mode 100644 index 0000000000..863253fb9d --- /dev/null +++ b/packages/app-store/dub/config.json @@ -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" +} diff --git a/packages/app-store/dub/index.ts b/packages/app-store/dub/index.ts new file mode 100644 index 0000000000..e2e9d7b029 --- /dev/null +++ b/packages/app-store/dub/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/dub/lib/AnalyticsService.ts b/packages/app-store/dub/lib/AnalyticsService.ts new file mode 100644 index 0000000000..fd2ca86e8f --- /dev/null +++ b/packages/app-store/dub/lib/AnalyticsService.ts @@ -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 { + 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 { + 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", + }); + } +} diff --git a/packages/app-store/dub/lib/index.ts b/packages/app-store/dub/lib/index.ts new file mode 100644 index 0000000000..8cac4b8d9a --- /dev/null +++ b/packages/app-store/dub/lib/index.ts @@ -0,0 +1 @@ +export { default as AnalyticsService } from "./AnalyticsService"; diff --git a/packages/app-store/dub/lib/type.ts b/packages/app-store/dub/lib/type.ts new file mode 100644 index 0000000000..a02023343c --- /dev/null +++ b/packages/app-store/dub/lib/type.ts @@ -0,0 +1,8 @@ +export interface DubOAuthToken { + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; + expiry_date?: number; + scope: string; +} diff --git a/packages/app-store/dub/lib/utils.ts b/packages/app-store/dub/lib/utils.ts new file mode 100644 index 0000000000..3d7c0a695d --- /dev/null +++ b/packages/app-store/dub/lib/utils.ts @@ -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(","); diff --git a/packages/app-store/dub/package.json b/packages/app-store/dub/package.json new file mode 100644 index 0000000000..ea1ce620ba --- /dev/null +++ b/packages/app-store/dub/package.json @@ -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." +} diff --git a/packages/app-store/dub/static/1.jpeg b/packages/app-store/dub/static/1.jpeg new file mode 100644 index 0000000000..4111fc5a54 Binary files /dev/null and b/packages/app-store/dub/static/1.jpeg differ diff --git a/packages/app-store/dub/static/2.jpeg b/packages/app-store/dub/static/2.jpeg new file mode 100644 index 0000000000..c6322acee7 Binary files /dev/null and b/packages/app-store/dub/static/2.jpeg differ diff --git a/packages/app-store/dub/static/3.jpeg b/packages/app-store/dub/static/3.jpeg new file mode 100644 index 0000000000..b0115f848e Binary files /dev/null and b/packages/app-store/dub/static/3.jpeg differ diff --git a/packages/app-store/dub/static/4.jpeg b/packages/app-store/dub/static/4.jpeg new file mode 100644 index 0000000000..9269197f78 Binary files /dev/null and b/packages/app-store/dub/static/4.jpeg differ diff --git a/packages/app-store/dub/static/icon-dark.svg b/packages/app-store/dub/static/icon-dark.svg new file mode 100644 index 0000000000..be95d20c9e --- /dev/null +++ b/packages/app-store/dub/static/icon-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app-store/dub/static/icon.svg b/packages/app-store/dub/static/icon.svg new file mode 100644 index 0000000000..7763014822 --- /dev/null +++ b/packages/app-store/dub/static/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 2058b530a2..031681ce0e 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -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")), diff --git a/packages/features/auth/lib/dub.ts b/packages/features/auth/lib/dub.ts index fc3317594f..96ee747885 100644 --- a/packages/features/auth/lib/dub.ts +++ b/packages/features/auth/lib/dub.ts @@ -1,4 +1,4 @@ -import { Dub } from "dub"; +import { Dub } from "dub-package"; export const dub = new Dub({ token: process.env.DUB_API_KEY, diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index b4ec747f9c..d3efd712ad 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -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, diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index f836f9c5fc..86ee729a47 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -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, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 78c1ed064e..f91a7131f4 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 = { diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index b28030d180..196b816050 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -20,6 +20,7 @@ type TaskPayloads = { createCRMEvent: z.infer; sendWorkflowEmails: z.infer; scanWorkflowBody: z.infer; + sendAnalyticsEvent: z.infer; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string) => Promise; diff --git a/packages/features/tasker/tasks/analytics/schema.ts b/packages/features/tasker/tasks/analytics/schema.ts new file mode 100644 index 0000000000..fd36654aa7 --- /dev/null +++ b/packages/features/tasker/tasks/analytics/schema.ts @@ -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(), + }), +}); diff --git a/packages/features/tasker/tasks/analytics/sendAnalyticsEvent.ts b/packages/features/tasker/tasks/analytics/sendAnalyticsEvent.ts new file mode 100644 index 0000000000..94410b7ecd --- /dev/null +++ b/packages/features/tasker/tasks/analytics/sendAnalyticsEvent.ts @@ -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 { + 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; + } +} diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 8262303be6..64119eff57 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -24,6 +24,8 @@ const tasks: Record Promise> = { 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 = { diff --git a/packages/lib/analyticsManager/analyticsManager.ts b/packages/lib/analyticsManager/analyticsManager.ts new file mode 100644 index 0000000000..2fe3f0b16a --- /dev/null +++ b/packages/lib/analyticsManager/analyticsManager.ts @@ -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); + } +} diff --git a/packages/lib/analyticsManager/handleAnalyticsEvents.ts b/packages/lib/analyticsManager/handleAnalyticsEvents.ts new file mode 100644 index 0000000000..9aec16c889 --- /dev/null +++ b/packages/lib/analyticsManager/handleAnalyticsEvents.ts @@ -0,0 +1,38 @@ +import tasker from "@calcom/features/tasker"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; + +interface HandleAnalyticsEventsProps { + credentials: CredentialForCalendarService[]; + rawBookingData: Record; + 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); + } +}; diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index 3ffafedff0..fe5f82cb8b 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -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, }: { diff --git a/packages/prisma/zod/custom/booking.ts b/packages/prisma/zod/custom/booking.ts index 1e51576761..9239ec14e8 100644 --- a/packages/prisma/zod/custom/booking.ts +++ b/packages/prisma/zod/custom/booking.ts @@ -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; diff --git a/packages/types/AnalyticsService.d.ts b/packages/types/AnalyticsService.d.ts new file mode 100644 index 0000000000..0028f9bc9a --- /dev/null +++ b/packages/types/AnalyticsService.d.ts @@ -0,0 +1,13 @@ +export interface SendEventProps { + name: string; + email: string; + id: string; + eventName: string; + externalId?: string; +} + +export interface AnalyticsService { + sendEvent(props: SendEventProps): Promise; +} + +export type AnalyticsServiceClass = Class;