diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx index 6e44bdac..a676f4b1 100644 --- a/apps/web/components/webhook/WebhookDialogForm.tsx +++ b/apps/web/components/webhook/WebhookDialogForm.tsx @@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: { subscriberUrl: "", active: true, payloadTemplate: null, - } as Omit, + } as Omit, } = props; const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate); diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx index f201e76d..e6a81cb0 100644 --- a/apps/web/lib/webhooks/sendPayload.tsx +++ b/apps/web/lib/webhooks/sendPayload.tsx @@ -1,3 +1,4 @@ +import { Webhook } from "@prisma/client"; import { compile } from "handlebars"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -24,13 +25,13 @@ function jsonParse(jsonString: string) { const sendPayload = async ( triggerEvent: string, createdAt: string, - subscriberUrl: string, + webhook: Pick, data: CalendarEvent & { metadata?: { [key: string]: string }; rescheduleUid?: string; - }, - template?: string | null + } ) => { + const { subscriberUrl, appId, payloadTemplate: template } = webhook; if (!subscriberUrl || !data) { throw new Error("Missing required elements to send webhook payload."); } @@ -38,13 +39,22 @@ const sendPayload = async ( const contentType = !template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded"; - const body = template - ? applyTemplate(template, data, contentType) - : JSON.stringify({ - triggerEvent: triggerEvent, - createdAt: createdAt, - payload: data, - }); + data.description = data.description || data.additionalNotes; + + let body; + + /* Zapier id is hardcoded in the DB, we send the raw data for this case */ + if (appId === "zapier") { + body = JSON.stringify(data); + } else if (template) { + body = applyTemplate(template, data, contentType); + } else { + body = JSON.stringify({ + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: data, + }); + } const response = await fetch(subscriberUrl, { method: "POST", diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx index dc8ac85c..25db083a 100644 --- a/apps/web/lib/webhooks/subscriptions.tsx +++ b/apps/web/lib/webhooks/subscriptions.tsx @@ -8,7 +8,7 @@ export type GetSubscriberOptions = { triggerEvent: WebhookTriggerEvents; }; -const getSubscribers = async (options: GetSubscriberOptions) => { +const getWebhooks = async (options: GetSubscriberOptions) => { const { userId, eventTypeId } = options; const allWebhooks = await prisma.webhook.findMany({ where: { @@ -32,10 +32,11 @@ const getSubscribers = async (options: GetSubscriberOptions) => { select: { subscriberUrl: true, payloadTemplate: true, + appId: true, }, }); return allWebhooks; }; -export default getSubscribers; +export default getWebhooks; diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 385a5021..baf2c17f 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -753,17 +753,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) metadata: reqBody.metadata, }); const promises = subscribers.map((sub) => - sendPayload( - eventTrigger, - new Date().toISOString(), - sub.subscriberUrl, - { - ...evt, - rescheduleUid, - metadata: reqBody.metadata, - }, - sub.payloadTemplate - ).catch((e) => { + sendPayload(eventTrigger, new Date().toISOString(), sub, { + ...evt, + rescheduleUid, + metadata: reqBody.metadata, + }).catch((e) => { console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); }) ); diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index d5dc45e6..9473efba 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -14,7 +14,7 @@ import { getSession } from "@lib/auth"; import { sendCancelledEmails } from "@lib/emails/email-manager"; import prisma from "@lib/prisma"; import sendPayload from "@lib/webhooks/sendPayload"; -import getSubscribers from "@lib/webhooks/subscriptions"; +import getWebhooks from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; @@ -136,13 +136,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) eventTypeId: (bookingToDelete.eventTypeId as number) || 0, triggerEvent: eventTrigger, }; - const subscribers = await getSubscribers(subscriberOptions); - const promises = subscribers.map((sub) => - sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( - (e) => { - console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); - } - ) + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { + console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); + }) ); await Promise.all(promises); diff --git a/apps/web/pages/api/integrations.ts b/apps/web/pages/api/integrations.ts index 76b0b296..06f40cb2 100644 --- a/apps/web/pages/api/integrations.ts +++ b/apps/web/pages/api/integrations.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; @@ -10,8 +11,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Check that user is authenticated const session = await getSession({ req }); + const userId = session?.user?.id; - if (!session) { + if (!userId) { res.status(401).json({ message: "You must be logged in to do this" }); return; } @@ -19,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === "GET") { const credentials = await prisma.credential.findMany({ where: { - userId: session.user?.id, + userId, }, select: { type: true, @@ -31,18 +33,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method == "DELETE") { const id = req.body.id; + const data: Prisma.UserUpdateInput = { + credentials: { + delete: { + id, + }, + }, + }; + const integration = await prisma.credential.findUnique({ + where: { + id, + }, + }); + /* If the user deletes a zapier integration, we delete all his api keys as well. */ + if (integration?.appId === "zapier") { + data.apiKeys = { + deleteMany: { + userId, + appId: "zapier", + }, + }; + /* We also delete all user's zapier wehbooks */ + data.webhooks = { + deleteMany: { + userId, + appId: "zapier", + }, + }; + } await prisma.user.update({ where: { - id: session?.user?.id, - }, - data: { - credentials: { - delete: { - id, - }, - }, + id: userId, }, + data, }); res.status(200).json({ message: "Integration deleted successfully" }); diff --git a/apps/web/pages/apps/setup/[appName].tsx b/apps/web/pages/apps/setup/[appName].tsx new file mode 100644 index 00000000..b8bdc7c6 --- /dev/null +++ b/apps/web/pages/apps/setup/[appName].tsx @@ -0,0 +1,38 @@ +import { useSession } from "next-auth/react"; +import { useRouter } from "next/router"; + +import _zapierMetadata from "@calcom/app-store/zapier/_metadata"; +import { ZapierSetup } from "@calcom/app-store/zapier/components"; + +import { trpc } from "@lib/trpc"; + +import Loader from "@components/Loader"; + +export default function SetupInformation() { + const router = useRouter(); + const appName = router.query.appName; + const { status } = useSession(); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "unauthenticated") { + router.replace({ + pathname: "/auth/login", + query: { + callbackUrl: `/apps/setup/${appName}`, + }, + }); + } + + if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") { + return ; + } + + return null; +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 33476b76..470d7425 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -774,5 +774,12 @@ "impersonate_user_tip":"All uses of this feature is audited.", "impersonating_user_warning":"Impersonating username \"{{user}}\".", "impersonating_stop_instructions": "<0>Click Here to stop.", + "setting_up_zapier": "Setting up your Zapier integration", + "generate_api_key": "Generate Api Key", + "your_unique_api_key": "Your unique API key", + "copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.", + "zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.<1>Select Cal.com as your Trigger app. Also choose a Trigger event.<2>Choose your account and then enter your Unique API Key.<3>Test your Trigger.<4>You're set!", + "install_zapier_app": "Please first install the Zapier App in the app store.", + "go_to_app_store": "Go to App Store", "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions" } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 25a81a58..7d59283c 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -660,5 +660,6 @@ "availability_updated_successfully": "Disponibilidad actualizada correctamente", "requires_ownership_of_a_token": "Requiere la propiedad de un token perteneciente a la siguiente dirección:", "example_name": "Juan Pérez", - "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} })." + "you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} }).", + "zapier_setup_instructions": "<0>Inicia sesión en tu cuenta de Zapier y crea un nuevo Zap.<1>Selecciona Cal.com cómo tu aplicación disparadora. Tambien elige tu evento disparador.<2>Elige tu cuenta e ingresa tu Clave API única.<3>Prueba tu disparador.<4>¡Listo!" } diff --git a/apps/web/server/routers/viewer/apiKeys.tsx b/apps/web/server/routers/viewer/apiKeys.tsx index 0b95986f..70c5f08d 100644 --- a/apps/web/server/routers/viewer/apiKeys.tsx +++ b/apps/web/server/routers/viewer/apiKeys.tsx @@ -11,16 +11,39 @@ export const apiKeysRouter = createProtectedRouter() return await ctx.prisma.apiKey.findMany({ where: { userId: ctx.user.id, + NOT: { + appId: "zapier", + }, }, orderBy: { createdAt: "desc" }, }); }, }) + .query("findKeyOfType", { + input: z.object({ + appId: z.string().optional().nullable(), + }), + async resolve({ ctx, input }) { + return await ctx.prisma.apiKey.findFirst({ + where: { + AND: [ + { + userId: ctx.user.id, + }, + { + appId: input.appId, + }, + ], + }, + }); + }, + }) .mutation("create", { input: z.object({ note: z.string().optional().nullish(), expiresAt: z.date().optional().nullable(), neverExpires: z.boolean().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { const [hashedApiKey, apiKey] = generateUniqueAPIKey(); diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx index 8386d854..0c161f38 100644 --- a/apps/web/server/routers/viewer/webhook.tsx +++ b/apps/web/server/routers/viewer/webhook.tsx @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import { v4 } from "uuid"; import { z } from "zod"; @@ -17,17 +18,18 @@ export const webhookRouter = createProtectedRouter() }) .optional(), async resolve({ ctx, input }) { - if (input?.eventTypeId) { - return await ctx.prisma.webhook.findMany({ - where: { - eventTypeId: input.eventTypeId, - }, - }); + let where: Prisma.WebhookWhereInput = { + AND: [{ appId: null /* Don't mixup zapier webhooks with normal ones */ }], + }; + if (Array.isArray(where.AND)) { + if (input?.eventTypeId) { + where.AND?.push({ eventTypeId: input.eventTypeId }); + } else { + where.AND?.push({ userId: ctx.user.id }); + } } return await ctx.prisma.webhook.findMany({ - where: { - userId: ctx.user.id, - }, + where, }); }, }) @@ -38,6 +40,7 @@ export const webhookRouter = createProtectedRouter() active: z.boolean(), payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { if (input.eventTypeId) { @@ -65,6 +68,7 @@ export const webhookRouter = createProtectedRouter() active: z.boolean().optional(), payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), + appId: z.string().optional().nullable(), }), async resolve({ ctx, input }) { const { id, ...data } = input; @@ -139,7 +143,7 @@ export const webhookRouter = createProtectedRouter() payloadTemplate: z.string().optional().nullable(), }), async resolve({ input }) { - const { url, type, payloadTemplate } = input; + const { url, type, payloadTemplate = null } = input; const translation = await getTranslation("en", "common"); const language = { locale: "en", @@ -170,7 +174,8 @@ export const webhookRouter = createProtectedRouter() }; try { - return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate); + const webhook = { subscriberUrl: url, payloadTemplate, appId: null }; + return await sendPayload(type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index ad441af5..3a93df35 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,5 +1,9 @@ const base = require("@calcom/config/tailwind-preset"); module.exports = { ...base, - content: [...base.content, "../../packages/ui/**/*.{js,ts,jsx,tsx}"], + content: [ + ...base.content, + "../../packages/ui/**/*.{js,ts,jsx,tsx}", + "../../packages/app-store/**/components/*.{js,ts,jsx,tsx}", + ], }; diff --git a/packages/app-store/_utils/getAppKeysFromSlug.ts b/packages/app-store/_utils/getAppKeysFromSlug.ts index f76fa9d0..a106ec96 100644 --- a/packages/app-store/_utils/getAppKeysFromSlug.ts +++ b/packages/app-store/_utils/getAppKeysFromSlug.ts @@ -4,7 +4,7 @@ import prisma from "@calcom/prisma"; async function getAppKeysFromSlug(slug: string) { const app = await prisma.app.findUnique({ where: { slug } }); - return app?.keys as Prisma.JsonObject; + return (app?.keys || {}) as Prisma.JsonObject; } export default getAppKeysFromSlug; diff --git a/packages/app-store/apiHandlers.tsx b/packages/app-store/apiHandlers.tsx index c8535136..c6b98804 100644 --- a/packages/app-store/apiHandlers.tsx +++ b/packages/app-store/apiHandlers.tsx @@ -15,6 +15,8 @@ export const apiHandlers = { huddle01video: import("./huddle01video/api"), metamask: import("./metamask/api"), giphy: import("./giphy/api"), + // @todo Until we use DB slugs everywhere + zapierother: import("./zapier/api"), }; export default apiHandlers; diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index 39ebc962..c36b7a08 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -21,6 +21,7 @@ export const InstallAppButtonMap = { zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")), office365video: dynamic(() => import("./office365video/components/InstallAppButton")), wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")), + zapier: dynamic(() => import("./zapier/components/InstallAppButton")), jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")), huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), metamask: dynamic(() => import("./metamask/components/InstallAppButton")), diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 60e0f17e..c7232b55 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -15,6 +15,7 @@ import * as slackmessaging from "./slackmessaging"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; import * as wipemycalother from "./wipemycalother"; +import * as zapier from "./zapier"; import * as zoomvideo from "./zoomvideo"; const appStore = { @@ -36,6 +37,7 @@ const appStore = { wipemycalother, metamask, giphy, + zapier, }; export default appStore; diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts index 210531a4..5fb28a64 100644 --- a/packages/app-store/metadata.ts +++ b/packages/app-store/metadata.ts @@ -14,6 +14,7 @@ import { metadata as slackmessaging } from "./slackmessaging/_metadata"; import { metadata as stripepayment } from "./stripepayment/_metadata"; import { metadata as tandemvideo } from "./tandemvideo/_metadata"; import { metadata as wipemycalother } from "./wipemycalother/_metadata"; +import { metadata as zapier } from "./zapier/_metadata"; import { metadata as zoomvideo } from "./zoomvideo/_metadata"; export const appStoreMetadata = { @@ -34,6 +35,7 @@ export const appStoreMetadata = { wipemycalother, metamask, giphy, + zapier, }; export default appStoreMetadata; diff --git a/packages/app-store/zapier/README.md b/packages/app-store/zapier/README.md new file mode 100644 index 00000000..f00a3752 --- /dev/null +++ b/packages/app-store/zapier/README.md @@ -0,0 +1,71 @@ + +
+ + Logo + +
+ +# Setting up Zapier Integration + +If you run it on localhost, check out the [additional information](https://github.com/CarinaWolli/cal.com/edit/feat/zapier-app/packages/app-store/zapier/README.md#localhost) below. + +1. Create [Zapier Account](https://zapier.com/sign-up?next=https%3A%2F%2Fdeveloper.zapier.com%2F) +2. If not redirected to developer account, go to: [Zapier Developer Account](https://developer.zapier.com) +3. Click **Start a Zapier Integration** +4. Create Integration + - Name: Cal.com + - Description: Cal.com is a scheduling infrastructure for absolutely everyone. + - Intended Audience: Private + - Role: choose whatever is appropriate + - Category: Calendar + +## Authentication + +1. Go to Authentication, choose Api key and click save +2. Click Add Fields + - Key: apiKey + - Check the box ‘is this field required?’ +3. Configure a Test + - Test: GET ``````/api/integrations/zapier/listBookings + - URL Params + - apiKey: {{bundle.authData.apiKey}} +4. Test your authentication —> First you have to install Zapier in the Cal.com App Store and generate an API key, use this API key to test your authentication (only zapier Api key works) + +## Triggers + +Booking created, Booking rescheduled, Booking cancelled + +### Booking created + +1. Settings + - Key: booking_created + - Name: Booking created + - Noun: Booking + - Description: Triggers when a new booking is created +2. API Configuration (apiKey is set automatically, leave it like it is): + - Trigger Type: REST Hook + - Subscribe: POST ``````/api/integrations/zapier/addSubscription + - Request Body + - subscriberUrl: {{bundle.targetUrl}} + - triggerEvent: BOOKING_CREATED + - Unsubscribe: DELETE ``````/api/integrations/zapier/deleteSubscription + - URL Params (in addition to apiKey) + - id: {{bundle.subscribeData.id}} + - PerformList: GET ``````/api/integrations/zapier/listBookings +3. Test your API request + +Create the other two triggers (booking rescheduled, booking cancelled) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created) + +### Testing integration + +Use the sharing link under Manage → Sharing to create your first Cal.com trigger in Zapier + +## Localhost + +Localhost urls can not be used as the base URL for api endpoints + +Possible solution: using [https://ngrok.com/](https://ngrok.com/) + +1. Create Account +2. [Download](https://ngrok.com/download) gnork and start a tunnel to your running localhost + - Use forwarding url as your baseUrl for the URL endpoints diff --git a/packages/app-store/zapier/README.mdx b/packages/app-store/zapier/README.mdx new file mode 100644 index 00000000..6246dc63 --- /dev/null +++ b/packages/app-store/zapier/README.mdx @@ -0,0 +1,5 @@ +Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking is created, rescheduled or cancelled. + +
+**After Installation:** You lost your generated API key? Here you can generate a new key and find all information +on how to use the installed app: [Zapier App Setup](http://localhost:3000/apps/setup/zapier) diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts new file mode 100644 index 00000000..6273c2d1 --- /dev/null +++ b/packages/app-store/zapier/_metadata.ts @@ -0,0 +1,25 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Zapier", + description: _package.description, + installed: true, + category: "other", + imageSrc: "/api/app-store/zapier/icon.svg", + logo: "/api/app-store/zapier/icon.svg", + publisher: "Cal.com", + rating: 0, + reviews: 0, + slug: "zapier", + title: "Zapier", + trending: true, + type: "zapier_other", + url: "https://cal.com/apps/zapier", + variant: "other", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/zapier/api/add.ts b/packages/app-store/zapier/api/add.ts new file mode 100644 index 00000000..0c01b593 --- /dev/null +++ b/packages/app-store/zapier/api/add.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + const appType = "zapier_other"; + try { + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + userId: req.session.user.id, + }, + }); + if (alreadyInstalled) { + throw new Error("Already installed"); + } + const installation = await prisma.credential.create({ + data: { + type: appType, + key: {}, + userId: req.session.user.id, + appId: "zapier", + }, + }); + if (!installation) { + throw new Error("Unable to create user credential for zapier"); + } + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } + + return res.status(200).json({ url: "/apps/setup/zapier" }); +} diff --git a/packages/app-store/zapier/api/index.ts b/packages/app-store/zapier/api/index.ts new file mode 100644 index 00000000..f3f3d07a --- /dev/null +++ b/packages/app-store/zapier/api/index.ts @@ -0,0 +1,4 @@ +export { default as add } from "./add"; +export { default as listBookings } from "./subscriptions/listBookings"; +export { default as deleteSubscription } from "./subscriptions/deleteSubscription"; +export { default as addSubscription } from "./subscriptions/addSubscription"; diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts new file mode 100644 index 00000000..d1e32c6c --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const { subscriberUrl, triggerEvent } = req.body; + + if (req.method === "POST") { + try { + const createSubscription = await prisma.webhook.create({ + data: { + id: v4(), + userId: validKey.userId, + eventTriggers: [triggerEvent], + subscriberUrl, + active: true, + appId: "zapier", + }, + }); + res.status(200).json(createSubscription); + } catch (error) { + return res.status(500).json({ message: "Could not create subscription." }); + } + } +} diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts new file mode 100644 index 00000000..52084db6 --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const id = req.query.id as string; + + if (req.method === "DELETE") { + await prisma.webhook.delete({ + where: { + id, + }, + }); + res.status(204).json({ message: "Subscription is deleted." }); + } +} diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts new file mode 100644 index 00000000..8751b06b --- /dev/null +++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/ee/lib/api/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "zapier"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + if (req.method === "GET") { + try { + const bookings = await prisma.booking.findMany({ + take: 3, + where: { + userId: validKey.userId, + }, + select: { + description: true, + startTime: true, + endTime: true, + title: true, + location: true, + cancellationReason: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + }, + }); + + res.status(201).json(bookings); + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Unable to get bookings." }); + } + } +} diff --git a/packages/app-store/zapier/components/InstallAppButton.tsx b/packages/app-store/zapier/components/InstallAppButton.tsx new file mode 100644 index 00000000..ee3e1d10 --- /dev/null +++ b/packages/app-store/zapier/components/InstallAppButton.tsx @@ -0,0 +1,18 @@ +import type { InstallAppButtonProps } from "@calcom/app-store/types"; + +import useAddAppMutation from "../../_utils/useAddAppMutation"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("zapier_other"); + + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/zapier/components/icon.tsx b/packages/app-store/zapier/components/icon.tsx new file mode 100644 index 00000000..b4c2401c --- /dev/null +++ b/packages/app-store/zapier/components/icon.tsx @@ -0,0 +1,15 @@ +export default function Icon() { + return ( + + + + ); +} diff --git a/packages/app-store/zapier/components/index.ts b/packages/app-store/zapier/components/index.ts new file mode 100644 index 00000000..5f2c7965 --- /dev/null +++ b/packages/app-store/zapier/components/index.ts @@ -0,0 +1,3 @@ +export { default as InstallAppButton } from "./InstallAppButton"; +export { default as ZapierSetup } from "./zapierSetup"; +export { default as Icon } from "./icon"; diff --git a/packages/app-store/zapier/components/zapierSetup.tsx b/packages/app-store/zapier/components/zapierSetup.tsx new file mode 100644 index 00000000..b981e06e --- /dev/null +++ b/packages/app-store/zapier/components/zapierSetup.tsx @@ -0,0 +1,121 @@ +import { ClipboardCopyIcon } from "@heroicons/react/solid"; +import { Trans } from "next-i18next"; +import Link from "next/link"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import { Button } from "@calcom/ui"; +import Loader from "@calcom/web/components/Loader"; +import { Tooltip } from "@calcom/web/components/Tooltip"; + +import Icon from "./icon"; + +interface IZapierSetupProps { + trpc: any; +} + +const ZAPIER = "zapier"; + +export default function ZapierSetup(props: IZapierSetupProps) { + const { trpc } = props; + const [newApiKey, setNewApiKey] = useState(""); + const { t } = useLocale(); + const utils = trpc.useContext(); + const integrations = trpc.useQuery(["viewer.integrations"]); + const oldApiKey = trpc.useQuery(["viewer.apiKeys.findKeyOfType", { appId: ZAPIER }]); + const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete"); + const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.other?.items.find( + (item: { type: string }) => item.type === "zapier_other" + ); + const [credentialId] = zapierCredentials?.credentialIds || [false]; + const showContent = integrations.data && integrations.isSuccess && credentialId; + + async function createApiKey() { + const event = { note: "Zapier", expiresAt: null, appId: ZAPIER }; + const apiKey = await utils.client.mutation("viewer.apiKeys.create", event); + if (oldApiKey.data) { + deleteApiKey.mutate({ + id: oldApiKey.data.id, + }); + } + setNewApiKey(apiKey); + } + + if (integrations.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {showContent ? ( +
+
+
+ +
+
+
{t("setting_up_zapier")}
+ {!newApiKey ? ( + <> +
{t("generate_api_key")}:
+ + + ) : ( + <> +
{t("your_unique_api_key")}
+
+
{newApiKey}
+ + + +
+
+ {t("copy_safe_api_key")} +
+ + )} + +
    + +
  1. Log into your Zapier account and create a new Zap.
  2. +
  3. Select Cal.com as your Trigger app. Also choose a Trigger event.
  4. +
  5. Choose your account and then enter your Unique API Key.
  6. +
  7. Test your Trigger.
  8. +
  9. You're set!
  10. +
    +
+ + + +
+
+
+ ) : ( +
+
{t("install_zapier_app")}
+
+ + + +
+
+ )} +
+ ); +} diff --git a/packages/app-store/zapier/index.ts b/packages/app-store/zapier/index.ts new file mode 100644 index 00000000..a698d1f5 --- /dev/null +++ b/packages/app-store/zapier/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as components from "./components"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/zapier/package.json b/packages/app-store/zapier/package.json new file mode 100644 index 00000000..1fcd4ab0 --- /dev/null +++ b/packages/app-store/zapier/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/zapier", + "version": "0.0.0", + "main": "./index.ts", + "description": "Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking was created, rescheduled or cancled.", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/zapier/static/icon.svg b/packages/app-store/zapier/static/icon.svg new file mode 100644 index 00000000..237b13e5 --- /dev/null +++ b/packages/app-store/zapier/static/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ee/lib/api/findValidApiKey.ts b/packages/ee/lib/api/findValidApiKey.ts new file mode 100644 index 00000000..2aeeb7c7 --- /dev/null +++ b/packages/ee/lib/api/findValidApiKey.ts @@ -0,0 +1,32 @@ +import { hashAPIKey } from "@calcom/ee/lib/api/apiKeys"; +import prisma from "@calcom/prisma"; + +const findValidApiKey = async (apiKey: string, appId?: string) => { + const hashedKey = hashAPIKey(apiKey.substring(process.env.API_KEY_PREFIX?.length || 0)); + + const validKey = await prisma.apiKey.findFirst({ + where: { + AND: [ + { + hashedKey, + }, + { + appId, + }, + ], + OR: [ + { + expiresAt: { + gte: new Date(Date.now()), + }, + }, + { + expiresAt: null, + }, + ], + }, + }); + return validKey; +}; + +export default findValidApiKey; diff --git a/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql b/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql new file mode 100644 index 00000000..b94dcd99 --- /dev/null +++ b/packages/prisma/migrations/20220503194835_adds_app_relation_to_webhook_and_api_keys/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "appId" TEXT; + +-- AlterTable +ALTER TABLE "Webhook" ADD COLUMN "appId" TEXT; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f67cf873..deb9e92a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -387,6 +387,8 @@ model Webhook { eventTriggers WebhookTriggerEvents[] user User? @relation(fields: [userId], references: [id], onDelete: Cascade) eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? } model Impersonations { @@ -407,6 +409,8 @@ model ApiKey { lastUsedAt DateTime? hashedKey String @unique() user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? } model HashedLink { @@ -464,4 +468,6 @@ model App { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt credentials Credential[] + Webhook Webhook[] + ApiKey ApiKey[] } diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index c233366c..8942b964 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -84,6 +84,7 @@ async function main() { api_key: process.env.GIPHY_API_KEY, }); } + await createApp("zapier", "zapier", ["other"], "zapier_other"); // Web3 apps await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video"); await createApp("metamask", "metamask", ["web3"], "metamask_web3"); diff --git a/packages/prisma/zod/webhook.ts b/packages/prisma/zod/webhook.ts index c6a86f1d..ec61dcd5 100755 --- a/packages/prisma/zod/webhook.ts +++ b/packages/prisma/zod/webhook.ts @@ -1,7 +1,7 @@ import * as z from "zod" import * as imports from "../zod-utils" import { WebhookTriggerEvents } from "@prisma/client" -import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index" +import { CompleteUser, UserModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index" export const _WebhookModel = z.object({ id: z.string(), @@ -12,11 +12,13 @@ export const _WebhookModel = z.object({ createdAt: z.date(), active: z.boolean(), eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(), + appId: z.string().nullish(), }) export interface CompleteWebhook extends z.infer { user?: CompleteUser | null eventType?: CompleteEventType | null + app?: CompleteApp | null } /** @@ -27,4 +29,5 @@ export interface CompleteWebhook extends z.infer { export const WebhookModel: z.ZodSchema = z.lazy(() => _WebhookModel.extend({ user: UserModel.nullish(), eventType: EventTypeModel.nullish(), + app: AppModel.nullish(), }))