diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index b9b37cb2fe..5117b121e4 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -11,6 +11,7 @@ import { appKeysSchema as giphy_zod_ts } from "./giphy/zod"; import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; import { appKeysSchema as gtm_zod_ts } from "./gtm/zod"; import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod"; +import { appKeysSchema as intercom_zod_ts } from "./intercom/zod"; import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appKeysSchema as make_zod_ts } from "./make/zod"; @@ -45,6 +46,7 @@ export const appKeysSchemas = { googlecalendar: googlecalendar_zod_ts, gtm: gtm_zod_ts, hubspot: hubspot_zod_ts, + intercom: intercom_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, make: make_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index c512487368..272d5f2aec 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -28,6 +28,7 @@ import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata"; import gtm_config_json from "./gtm/config.json"; import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata"; import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata"; +import intercom_config_json from "./intercom/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; import make_config_json from "./make/config.json"; @@ -101,6 +102,7 @@ export const appStoreMetadata = { gtm: gtm_config_json, hubspot: hubspot__metadata_ts, huddle01video: huddle01video__metadata_ts, + intercom: intercom_config_json, jitsivideo: jitsivideo__metadata_ts, larkcalendar: larkcalendar__metadata_ts, make: make_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 030ef8b6da..08d223e02d 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -11,6 +11,7 @@ import { appDataSchema as giphy_zod_ts } from "./giphy/zod"; import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; import { appDataSchema as gtm_zod_ts } from "./gtm/zod"; import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod"; +import { appDataSchema as intercom_zod_ts } from "./intercom/zod"; import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appDataSchema as make_zod_ts } from "./make/zod"; @@ -45,6 +46,7 @@ export const appDataSchemas = { googlecalendar: googlecalendar_zod_ts, gtm: gtm_zod_ts, hubspot: hubspot_zod_ts, + intercom: intercom_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, make: make_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 1b4d268f6b..5698485a62 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -28,6 +28,7 @@ export const apiHandlers = { gtm: import("./gtm/api"), hubspot: import("./hubspot/api"), huddle01video: import("./huddle01video/api"), + intercom: import("./intercom/api"), jitsivideo: import("./jitsivideo/api"), larkcalendar: import("./larkcalendar/api"), make: import("./make/api"), diff --git a/packages/app-store/intercom/DESCRIPTION.md b/packages/app-store/intercom/DESCRIPTION.md new file mode 100644 index 0000000000..61e7037d43 --- /dev/null +++ b/packages/app-store/intercom/DESCRIPTION.md @@ -0,0 +1,8 @@ +--- +items: + - 1.png + - 2.png + - 3.png +--- + +{DESCRIPTION} diff --git a/packages/app-store/intercom/api/add.ts b/packages/app-store/intercom/api/add.ts new file mode 100644 index 0000000000..5487ed3bb2 --- /dev/null +++ b/packages/app-store/intercom/api/add.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { stringify } from "querystring"; + +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; + +let client_id = ""; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") { + const appKeys = await getAppKeysFromSlug("intercom"); + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (!client_id) return res.status(400).json({ message: "Intercom client_id missing." }); + + const state = encodeOAuthState(req); + + const params = { + client_id, + redirect_uri: WEBAPP_URL_FOR_OAUTH + "/api/integrations/intercom/callback", + state, + response_type: "code", + }; + + const authUrl = `https://app.intercom.com/oauth?${stringify(params)}`; + + res.status(200).json({ url: authUrl }); + } +} diff --git a/packages/app-store/intercom/api/callback.ts b/packages/app-store/intercom/api/callback.ts new file mode 100644 index 0000000000..5fd5f861ba --- /dev/null +++ b/packages/app-store/intercom/api/callback.ts @@ -0,0 +1,92 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; + +const log = logger.getChildLogger({ prefix: [`[[intercom/api/callback]`] }); + +let client_id = ""; +let client_secret = ""; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + + 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" }); + } + + const appKeys = await getAppKeysFromSlug("intercom"); + + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; + if (!client_id) return res.status(400).json({ message: "Intercom client_id missing." }); + if (!client_secret) return res.status(400).json({ message: "Intercom client_secret missing." }); + + const response = await fetch(`https://api.intercom.io/auth/eagle/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + client_id, + client_secret, + }), + }); + + const responseBody = await response.json(); + + if (response.status !== 200) { + log.error("get user_access_token failed", responseBody); + return res.redirect("/apps/installed?error=" + JSON.stringify(responseBody)); + } + + // Find the admin id from the accompte thanks to access_token and store it + const admin = await fetch(`https://api.intercom.io/me`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${responseBody.access_token}`, + }, + }); + + const adminBody = await admin.json(); + + if (admin.status !== 200) { + log.error("get admin_id failed", adminBody); + return res.redirect("/apps/installed?error=" + JSON.stringify(adminBody)); + } + + const adminId = adminBody.id; + + // Remove the previous credential if admin id was already linked + await prisma.credential.deleteMany({ + where: { + type: "intercom_automation", + key: { + string_contains: adminId, + }, + }, + }); + + createOAuthAppCredential( + { appId: "intercom", type: "intercom_automation" }, + JSON.stringify({ access_token: responseBody.access_token, admin_id: adminId }), + req + ); + + res.redirect( + getSafeRedirectUrl(CAL_URL + "/apps/installed/automation?hl=intercom") ?? + getInstalledAppPath({ variant: "automation", slug: "intercom" }) + ); +} diff --git a/packages/app-store/intercom/api/configure.ts b/packages/app-store/intercom/api/configure.ts new file mode 100644 index 0000000000..3465369510 --- /dev/null +++ b/packages/app-store/intercom/api/configure.ts @@ -0,0 +1,136 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { CAL_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import type { + NewCanvas, + ListComponent, + ListItem, + SpacerComponent, + TextComponent, + InputComponent, +} from "../lib"; +import { isValidCalURL } from "../lib/isValidCalURL"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { admin, input_values, component_id } = req.body; + + let isValid: boolean | TextComponent = true; + if (component_id || input_values?.submit_booking_url) { + const url = component_id || input_values?.submit_booking_url; + isValid = await isValidCalURL(url); + + if (isValid) return res.status(200).json({ results: { submit_booking_url: url } }); + } + + const input: InputComponent = { + type: "input", + id: "submit_booking_url", + label: "Enter your Cal.com link", + placeholder: "https://cal.com/valentinchmara/30min", + save_state: "unsaved", + action: { + type: "submit", + }, + aria_label: "Enter your Cal.com link", + }; + + const defaultCanvasData: NewCanvas = { + canvas: { + content: { + components: isValid === true ? [input] : [isValid, input], + }, + }, + }; + + if (!admin?.id) return res.status(200).json(defaultCanvasData); + + const credential = await prisma.credential.findFirst({ + where: { + appId: "intercom", + key: { + string_contains: admin.id, + }, + }, + }); + + if (!credential) return res.status(200).json(defaultCanvasData); + + const team = credential.teamId + ? await prisma.team.findUnique({ + where: { + id: credential.teamId, + }, + }) + : null; + + const userId = credential.userId; + + const user = userId + ? await prisma.user.findUnique({ + where: { + id: userId, + }, + }) + : null; + + const eventTypes = await prisma.eventType.findMany({ + where: { + userId, + hidden: false, + }, + }); + + if (!eventTypes) return res.status(200).json(defaultCanvasData); + if (!user && !team) return res.status(200).json(defaultCanvasData); + + const list: ListItem[] = eventTypes.map((eventType) => { + let slug; + if (team && team.slug) { + slug = `team/${team.slug}`; + } else if (user && user.username) { + slug = user.username; + } + + return { + id: `${CAL_URL}/${slug}/${eventType.slug}`, + type: "item", + title: eventType.title, + subtitle: `${slug}/${eventType.slug}`, + rounded_image: false, + disabled: false, + action: { + type: "submit", + }, + }; + }); + + const components: ListComponent = { + type: "list", + items: list, + }; + + const spacer: SpacerComponent = { + type: "spacer", + size: "m", + }; + + const text: TextComponent = { + type: "text", + text: "Or choose another Cal.com link:", + style: "muted", + align: "left", + }; + + const canvasData: NewCanvas = { + canvas: { + content: { + components: + isValid === true ? [components, spacer, text, input] : [isValid, components, spacer, text, input], + }, + }, + }; + + return res.status(200).json(canvasData); +} diff --git a/packages/app-store/intercom/api/index.ts b/packages/app-store/intercom/api/index.ts new file mode 100644 index 0000000000..11e6b47870 --- /dev/null +++ b/packages/app-store/intercom/api/index.ts @@ -0,0 +1,4 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; +export { default as initialize } from "./initialize"; +export { default as configure } from "./configure"; diff --git a/packages/app-store/intercom/api/initialize.ts b/packages/app-store/intercom/api/initialize.ts new file mode 100644 index 0000000000..a5480c1209 --- /dev/null +++ b/packages/app-store/intercom/api/initialize.ts @@ -0,0 +1,32 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import type { NewCanvas } from "../lib"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { card_creation_options } = req.body; + + if (!card_creation_options) return res.status(400).json({ message: "Missing card_creation_options" }); + + const URL = card_creation_options.submit_booking_url; + + const canvasData: NewCanvas = { + canvas: { + content: { + components: [ + { + type: "button", + id: "submit-issue-form", + label: "Book a meeting", + style: "primary", + action: { + type: "sheet", + url: URL, + }, + }, + ], + }, + }, + }; + + return res.status(200).json(canvasData); +} diff --git a/packages/app-store/intercom/components/.gitkeep b/packages/app-store/intercom/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/intercom/config.json b/packages/app-store/intercom/config.json new file mode 100644 index 0000000000..5b4b0b0186 --- /dev/null +++ b/packages/app-store/intercom/config.json @@ -0,0 +1,19 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Intercom", + "slug": "intercom", + "type": "intercom_automation", + "logo": "icon.svg", + "url": "https://github.com/vachmara", + "variant": "automation", + "categories": [ + "automation" + ], + "publisher": "Valentin Chmara", + "email": "valentinchmara@gmail.com", + "description": "Enhance your scheduling and appointment management experience with the Intercom Integration for Cal.com.", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "basic", + "dirName": "intercom" +} diff --git a/packages/app-store/intercom/index.ts b/packages/app-store/intercom/index.ts new file mode 100644 index 0000000000..d7f3602204 --- /dev/null +++ b/packages/app-store/intercom/index.ts @@ -0,0 +1 @@ +export * as api from "./api"; diff --git a/packages/app-store/intercom/lib/index.ts b/packages/app-store/intercom/lib/index.ts new file mode 100644 index 0000000000..c4986b9075 --- /dev/null +++ b/packages/app-store/intercom/lib/index.ts @@ -0,0 +1,66 @@ +export interface CanvasComponent { + type: string; + disabled?: boolean; +} + +export interface InputComponent extends CanvasComponent { + type: "input"; + id: string; + label: string; + placeholder: string; + save_state: "unsaved" | "saved"; + action: { + type: "submit"; + }; + aria_label: string; +} + +interface ButtonComponent extends CanvasComponent { + type: "button"; + id: string; + label: string; + style: "primary" | "secondary" | "link"; + action: { + type: "submit" | "sheet" | "url"; + url?: string; + }; +} + +export interface SpacerComponent extends CanvasComponent { + type: "spacer"; + size: "s" | "m" | "l"; +} + +export interface TextComponent extends CanvasComponent { + type: "text"; + text: string; + style: "header" | "body" | "error" | "muted"; + align: "left" | "center" | "right"; +} + +export interface ListItem { + id: string; + type: "item"; + title: string; + subtitle: string; + rounded_image: boolean; + disabled: boolean; + action: { + type: "submit"; + }; +} + +export interface ListComponent extends CanvasComponent { + type: "list"; + items: ListItem[]; +} + +export interface CanvasContent { + components: (InputComponent | SpacerComponent | TextComponent | ListComponent | ButtonComponent)[]; +} + +export interface NewCanvas { + canvas: { + content: CanvasContent; + }; +} diff --git a/packages/app-store/intercom/lib/isValidCalURL.ts b/packages/app-store/intercom/lib/isValidCalURL.ts new file mode 100644 index 0000000000..c5690eabfa --- /dev/null +++ b/packages/app-store/intercom/lib/isValidCalURL.ts @@ -0,0 +1,87 @@ +import { CAL_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import type { TextComponent } from "../lib"; + +/** + * Check if the url is a valid cal.com url + * @param url + * @returns boolean + */ +export async function isValidCalURL(url: string) { + const regex = new RegExp(`^${CAL_URL}/`, `i`); + + const error: TextComponent = { + type: "text", + text: `This is not a valid ${CAL_URL.replace("https://", "")} link`, + style: "error", + align: "left", + }; + + if (!regex.test(url)) return error; + + const urlWithoutCal = url.replace(regex, ""); + + const urlParts = urlWithoutCal.split("/"); + const usernameOrTeamSlug = urlParts[0]; + const eventTypeSlug = urlParts[1]; + + if (!usernameOrTeamSlug || !eventTypeSlug) return error; + + // Find all potential users with the given username + const potentialUsers = await prisma.user.findMany({ + where: { + username: usernameOrTeamSlug, + }, + include: { + eventTypes: { + where: { + slug: eventTypeSlug, + hidden: false, + }, + }, + }, + }); + + // Find all potential teams with the given slug + const potentialTeams = await prisma.team.findMany({ + where: { + slug: usernameOrTeamSlug, + }, + include: { + eventTypes: { + where: { + slug: eventTypeSlug, + hidden: false, + }, + }, + }, + }); + + // Check if any user has the matching eventTypeSlug + const matchingUser = potentialUsers.find((user) => user.eventTypes.length > 0); + + // Check if any team has the matching eventTypeSlug + const matchingTeam = potentialTeams.find((team) => team.eventTypes.length > 0); + + if (!matchingUser && !matchingTeam) return error; + + const userOrTeam = matchingUser || matchingTeam; + + if (!userOrTeam) return error; + + // Retrieve the correct user or team + const userOrTeamId = userOrTeam.id; + + const eventType = await prisma.eventType.findFirst({ + where: { + userId: userOrTeamId, + slug: eventTypeSlug, + hidden: false, + }, + }); + + if (!eventType) return error; + + return true; +} diff --git a/packages/app-store/intercom/package.json b/packages/app-store/intercom/package.json new file mode 100644 index 0000000000..6af828037b --- /dev/null +++ b/packages/app-store/intercom/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/intercom", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "Enhance your scheduling and appointment management experience with the Intercom Integration for Cal.com." +} diff --git a/packages/app-store/intercom/static/1.png b/packages/app-store/intercom/static/1.png new file mode 100644 index 0000000000..a61e8e5e81 Binary files /dev/null and b/packages/app-store/intercom/static/1.png differ diff --git a/packages/app-store/intercom/static/2.png b/packages/app-store/intercom/static/2.png new file mode 100644 index 0000000000..92e5b8f15a Binary files /dev/null and b/packages/app-store/intercom/static/2.png differ diff --git a/packages/app-store/intercom/static/3.png b/packages/app-store/intercom/static/3.png new file mode 100644 index 0000000000..f3d34792c4 Binary files /dev/null and b/packages/app-store/intercom/static/3.png differ diff --git a/packages/app-store/intercom/static/icon.svg b/packages/app-store/intercom/static/icon.svg new file mode 100644 index 0000000000..78706aa5c9 --- /dev/null +++ b/packages/app-store/intercom/static/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app-store/intercom/zod.ts b/packages/app-store/intercom/zod.ts new file mode 100644 index 0000000000..103cde7c29 --- /dev/null +++ b/packages/app-store/intercom/zod.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +});