Feat/zapier app (#2623)

* create basic app structure

* add zapierSubscription model to prisma.schema

* change column name triggerEvent to lower case

* add zapier functionality + enpoints + adjust prisma.schema

* add subscriptionType + refactor code

* add app store information

* create setup page to generate api key

* clean code

* add copy functionality in setup page

* clean code

* add apiKeyType and delte key when uninstalled or new key generated

* clean code

* use Promise.all

* only approve zapier api key

* clean code

* fix findValidApiKey for api keys that don't expire

* fix migrations

* clean code

* small fixes

* add i18n

* add README.md file

* add setup guide to README.md

* fix yarn.lock

* Renames zapierother to zapier

* Typo

* Updates package name

* Rename fixes

* Adds zapier to the App Store seeder

* Adds missing zapier to apiHandlers

* Adds credential relationship to App

* Rename fixes

* Allows tailwind to pick up custom app-store components

* Consolidates zapier_setup_instructions

* Webhook fixes

* Uses app relationship instead of custom type

* Refactors sendPayload to accept webhook object

Instead of individual parameters

* refactoring

* Removes unused zapier check

* Update cancel.ts

* Refactoring

* Removes example comments

* Update InstallAppButton.tsx

* Type fixes

* E2E fixes

* Deletes all user zapier webhooks on integration removal

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Carina Wollendorfer 2022-05-04 01:16:59 +02:00 committed by GitHub
parent ba283e3dc0
commit 02b935bcde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 660 additions and 57 deletions

View File

@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: {
subscriberUrl: "",
active: true,
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);

View File

@ -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<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
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",

View File

@ -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;

View File

@ -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);
})
);

View File

@ -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);

View File

@ -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" });

View File

@ -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 (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
if (status === "unauthenticated") {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `/apps/setup/${appName}`,
},
});
}
if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") {
return <ZapierSetup trpc={trpc}></ZapierSetup>;
}
return null;
}

View File

@ -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</0>.",
"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.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
"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"
}

View File

@ -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.</0><1>Selecciona Cal.com cómo tu aplicación disparadora. Tambien elige tu evento disparador.</1><2>Elige tu cuenta e ingresa tu Clave API única.</2><3>Prueba tu disparador.</3><4>¡Listo!</4>"
}

View File

@ -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();

View File

@ -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 {

View File

@ -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}",
],
};

View File

@ -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;

View File

@ -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;

View File

@ -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")),

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,71 @@
<!-- PROJECT LOGO -->
<div align="center">
<a href="https://cal.com/enterprise">
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
</a>
</div>
# 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 ```<baseUrl>```/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 ```<baseUrl>```/api/integrations/zapier/addSubscription
- Request Body
- subscriberUrl: {{bundle.targetUrl}}
- triggerEvent: BOOKING_CREATED
- Unsubscribe: DELETE ```<baseUrl>```/api/integrations/zapier/deleteSubscription
- URL Params (in addition to apiKey)
- id: {{bundle.subscribeData.id}}
- PerformList: GET ```<baseUrl>```/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

View File

@ -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.
<br />
**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)

View File

@ -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;

View File

@ -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" });
}

View File

@ -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";

View File

@ -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." });
}
}
}

View File

@ -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." });
}
}

View File

@ -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." });
}
}
}

View File

@ -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,
})}
</>
);
}

View File

@ -0,0 +1,15 @@
export default function Icon() {
return (
<svg
width="40"
height="40"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid">
<path
d="M159.999 128.056a76.55 76.55 0 0 1-4.915 27.024 76.745 76.745 0 0 1-27.032 4.923h-.108c-9.508-.012-18.618-1.75-27.024-4.919A76.557 76.557 0 0 1 96 128.056v-.112a76.598 76.598 0 0 1 4.91-27.02A76.492 76.492 0 0 1 127.945 96h.108a76.475 76.475 0 0 1 27.032 4.923 76.51 76.51 0 0 1 4.915 27.02v.112zm94.223-21.389h-74.716l52.829-52.833a128.518 128.518 0 0 0-13.828-16.349v-.004a129 129 0 0 0-16.345-13.816l-52.833 52.833V1.782A128.606 128.606 0 0 0 128.064 0h-.132c-7.248.004-14.347.62-21.265 1.782v74.716L53.834 23.665A127.82 127.82 0 0 0 37.497 37.49l-.028.02A128.803 128.803 0 0 0 23.66 53.834l52.837 52.833H1.782S0 120.7 0 127.956v.088c0 7.256.615 14.367 1.782 21.289h74.716l-52.837 52.833a128.91 128.91 0 0 0 30.173 30.173l52.833-52.837v74.72a129.3 129.3 0 0 0 21.24 1.778h.181a129.15 129.15 0 0 0 21.24-1.778v-74.72l52.838 52.837a128.994 128.994 0 0 0 16.341-13.82l.012-.012a129.245 129.245 0 0 0 13.816-16.341l-52.837-52.833h74.724c1.163-6.91 1.77-14 1.778-21.24v-.186c-.008-7.24-.615-14.33-1.778-21.24z"
fill="#FF4A00"
/>
</svg>
);
}

View File

@ -0,0 +1,3 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as ZapierSetup } from "./zapierSetup";
export { default as Icon } from "./icon";

View File

@ -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 (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
return (
<div className="flex h-screen bg-gray-200">
{showContent ? (
<div className="m-auto rounded bg-white p-10">
<div className="flex flex-row">
<div className="mr-5">
<Icon />
</div>
<div className="ml-5">
<div className="text-gray-600">{t("setting_up_zapier")}</div>
{!newApiKey ? (
<>
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
<Button onClick={() => createApiKey()} className="mt-4 mb-4">
{t("generate_api_key")}
</Button>
</>
) : (
<>
<div className="mt-1 text-xl">{t("your_unique_api_key")}</div>
<div className="my-2 mt-3 flex">
<div className="mr-1 w-full rounded bg-gray-100 p-3 pr-5">{newApiKey}</div>
<Tooltip content="copy to clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(newApiKey);
showToast(t("api_key_copied"), "success");
}}
type="button"
className="px-4 text-base ">
<ClipboardCopyIcon className="mr-2 h-5 w-5 text-neutral-100" />
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="mt-2 mb-5 text-sm font-semibold text-gray-600">
{t("copy_safe_api_key")}
</div>
</>
)}
<ol className="mt-5 mb-5 mr-5 list-decimal">
<Trans i18nKey="zapier_setup_instructions">
<li>Log into your Zapier account and create a new Zap.</li>
<li>Select Cal.com as your Trigger app. Also choose a Trigger event.</li>
<li>Choose your account and then enter your Unique API Key.</li>
<li>Test your Trigger.</li>
<li>You&apos;re set!</li>
</Trans>
</ol>
<Link href={"/apps/installed"} passHref={true}>
<Button color="secondary">{t("done")}</Button>
</Link>
</div>
</div>
</div>
) : (
<div className="mt-5 ml-5">
<div>{t("install_zapier_app")}</div>
<div className="mt-3">
<Link href={"/apps/zapier"} passHref={true}>
<Button>{t("go_to_app_store")}</Button>
</Link>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,3 @@
export * as api from "./api";
export * as components from "./components";
export { metadata } from "./_metadata";

View File

@ -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": "*"
}
}

View File

@ -0,0 +1,3 @@
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
<path d="M159.999 128.056a76.55 76.55 0 0 1-4.915 27.024 76.745 76.745 0 0 1-27.032 4.923h-.108c-9.508-.012-18.618-1.75-27.024-4.919A76.557 76.557 0 0 1 96 128.056v-.112a76.598 76.598 0 0 1 4.91-27.02A76.492 76.492 0 0 1 127.945 96h.108a76.475 76.475 0 0 1 27.032 4.923 76.51 76.51 0 0 1 4.915 27.02v.112zm94.223-21.389h-74.716l52.829-52.833a128.518 128.518 0 0 0-13.828-16.349v-.004a129 129 0 0 0-16.345-13.816l-52.833 52.833V1.782A128.606 128.606 0 0 0 128.064 0h-.132c-7.248.004-14.347.62-21.265 1.782v74.716L53.834 23.665A127.82 127.82 0 0 0 37.497 37.49l-.028.02A128.803 128.803 0 0 0 23.66 53.834l52.837 52.833H1.782S0 120.7 0 127.956v.088c0 7.256.615 14.367 1.782 21.289h74.716l-52.837 52.833a128.91 128.91 0 0 0 30.173 30.173l52.833-52.837v74.72a129.3 129.3 0 0 0 21.24 1.778h.181a129.15 129.15 0 0 0 21.24-1.778v-74.72l52.838 52.837a128.994 128.994 0 0 0 16.341-13.82l.012-.012a129.245 129.245 0 0 0 13.816-16.341l-52.837-52.833h74.724c1.163-6.91 1.77-14 1.778-21.24v-.186c-.008-7.24-.615-14.33-1.778-21.24z" fill="#FF4A00"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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;

View File

@ -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;

View File

@ -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[]
}

View File

@ -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");

View File

@ -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<typeof _WebhookModel> {
user?: CompleteUser | null
eventType?: CompleteEventType | null
app?: CompleteApp | null
}
/**
@ -27,4 +29,5 @@ export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
user: UserModel.nullish(),
eventType: EventTypeModel.nullish(),
app: AppModel.nullish(),
}))