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:
parent
ba283e3dc0
commit
02b935bcde
37 changed files with 660 additions and 57 deletions
|
@ -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);
|
||||
|
|
|
@ -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({
|
||||
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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub, {
|
||||
...evt,
|
||||
rescheduleUid,
|
||||
metadata: reqBody.metadata,
|
||||
},
|
||||
sub.payloadTemplate
|
||||
).catch((e) => {
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,19 +33,41 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
if (req.method == "DELETE") {
|
||||
const id = req.body.id;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session?.user?.id,
|
||||
},
|
||||
data: {
|
||||
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: userId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Integration deleted successfully" });
|
||||
}
|
||||
|
|
38
apps/web/pages/apps/setup/[appName].tsx
Normal file
38
apps/web/pages/apps/setup/[appName].tsx
Normal 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;
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>"
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 }) {
|
||||
let where: Prisma.WebhookWhereInput = {
|
||||
AND: [{ appId: null /* Don't mixup zapier webhooks with normal ones */ }],
|
||||
};
|
||||
if (Array.isArray(where.AND)) {
|
||||
if (input?.eventTypeId) {
|
||||
return await ctx.prisma.webhook.findMany({
|
||||
where: {
|
||||
eventTypeId: 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 {
|
||||
|
|
|
@ -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}",
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
71
packages/app-store/zapier/README.md
Normal file
71
packages/app-store/zapier/README.md
Normal 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
|
5
packages/app-store/zapier/README.mdx
Normal file
5
packages/app-store/zapier/README.mdx
Normal 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)
|
25
packages/app-store/zapier/_metadata.ts
Normal file
25
packages/app-store/zapier/_metadata.ts
Normal 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;
|
39
packages/app-store/zapier/api/add.ts
Normal file
39
packages/app-store/zapier/api/add.ts
Normal 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" });
|
||||
}
|
4
packages/app-store/zapier/api/index.ts
Normal file
4
packages/app-store/zapier/api/index.ts
Normal 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";
|
|
@ -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." });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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." });
|
||||
}
|
||||
}
|
49
packages/app-store/zapier/api/subscriptions/listBookings.ts
Normal file
49
packages/app-store/zapier/api/subscriptions/listBookings.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
}
|
18
packages/app-store/zapier/components/InstallAppButton.tsx
Normal file
18
packages/app-store/zapier/components/InstallAppButton.tsx
Normal 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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
15
packages/app-store/zapier/components/icon.tsx
Normal file
15
packages/app-store/zapier/components/icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
packages/app-store/zapier/components/index.ts
Normal file
3
packages/app-store/zapier/components/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as InstallAppButton } from "./InstallAppButton";
|
||||
export { default as ZapierSetup } from "./zapierSetup";
|
||||
export { default as Icon } from "./icon";
|
121
packages/app-store/zapier/components/zapierSetup.tsx
Normal file
121
packages/app-store/zapier/components/zapierSetup.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
3
packages/app-store/zapier/index.ts
Normal file
3
packages/app-store/zapier/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * as api from "./api";
|
||||
export * as components from "./components";
|
||||
export { metadata } from "./_metadata";
|
14
packages/app-store/zapier/package.json
Normal file
14
packages/app-store/zapier/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
3
packages/app-store/zapier/static/icon.svg
Normal file
3
packages/app-store/zapier/static/icon.svg
Normal 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 |
32
packages/ee/lib/api/findValidApiKey.ts
Normal file
32
packages/ee/lib/api/findValidApiKey.ts
Normal 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;
|
|
@ -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;
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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(),
|
||||
}))
|
||||
|
|
Loading…
Reference in a new issue