Feature/cal 274 add webhooks (#628)

* added prisma models and migration, minor webhook init --WIP

* --WIP

* --WIP

* added radix-checkbox and other webhook additions --WIP

* added API connections and other modifications --WIP

* --WIP

* replaced checkbox with toggle --WIP

* updated to use Dialog instead of modal --WIP

* fixed API and other small fixes -WIP

* created a dummy hook for test --WIP

* replaced static hook with dynamic hooks

* yarn lock conflict quickfix

* added cancel event hook and other minor additions --WIP

* minor improvements --WIP

* added more add-webhook flow items--WIP

* updated migration to have alter table for eventType

* many ui/ux fixes, logic fixes and action fixes --WIP

* bugfix for incorrect webhook filtering

* some more fixes, edit webhook --WIP

* removed redundant checkbox

* more bugfixes and edit-webhook flow --WIP

* more build and lint fixes

* --WIP

* more fixes and added toast notif --WIP

* --updated iconButton

* clean-up

* fixed enabled check in edit webhook

* another fix

* fixed edit webhook bug

* added await to payload lambda

* wrapped payload call in promise

* fixed cancel/uid CTA alignment

* --requested changes --removed eventType relationship

* Adds missing migration

* Fixes missing daysjs plugin and type fixes

* Adds failsafe for webhooks

* Adds missing dayjs utc plugins

* Fixed schema and migrations

* Updates webhooks query

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
pull/854/head
Syed Ali Shahbaz 2021-10-05 05:10:52 +05:30 committed by GitHub
parent 785058558c
commit 4c07faefe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 956 additions and 62 deletions

View File

@ -15,7 +15,7 @@ export default function SettingsShell({ children }: { children: React.ReactNode
href: "/settings/security",
icon: KeyIcon,
},
{ name: "Embed", href: "/settings/embed", icon: CodeIcon },
{ name: "Embed & Webhooks", href: "/settings/embed", icon: CodeIcon },
{
name: "Teams",
href: "/settings/teams",

View File

@ -0,0 +1,176 @@
import { ArrowLeftIcon } from "@heroicons/react/solid";
import { EventType } from "@prisma/client";
import { useEffect, useRef, useState } from "react";
import showToast from "@lib/notification";
import { Webhook } from "@lib/webhook";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
export default function EditTeam(props: {
webhook: Webhook;
eventTypes: EventType[];
onCloseEdit: () => void;
}) {
const [bookingCreated, setBookingCreated] = useState(
props.webhook.eventTriggers.includes("booking_created")
);
const [bookingRescheduled, setBookingRescheduled] = useState(
props.webhook.eventTriggers.includes("booking_rescheduled")
);
const [bookingCancelled, setBookingCancelled] = useState(
props.webhook.eventTriggers.includes("booking_cancelled")
);
const [webhookEnabled, setWebhookEnabled] = useState(props.webhook.active);
const [webhookEventTrigger, setWebhookEventTriggers] = useState([
"BOOKING_CREATED",
"BOOKING_RESCHEDULED",
"BOOKING_CANCELLED",
]);
const [btnLoading, setBtnLoading] = useState(false);
const subUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
useEffect(() => {
const arr = [];
bookingCreated && arr.push("BOOKING_CREATED");
bookingRescheduled && arr.push("BOOKING_RESCHEDULED");
bookingCancelled && arr.push("BOOKING_CANCELLED");
setWebhookEventTriggers(arr);
}, [bookingCreated, bookingRescheduled, bookingCancelled, webhookEnabled]);
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const updateWebhookHandler = (event) => {
event.preventDefault();
setBtnLoading(true);
return fetch("/api/webhooks/" + props.webhook.id, {
method: "PATCH",
body: JSON.stringify({
subscriberUrl: subUrlRef.current.value,
eventTriggers: webhookEventTrigger,
enabled: webhookEnabled,
}),
headers: {
"Content-Type": "application/json",
},
})
.then(handleErrors)
.then(() => {
showToast("Webhook updated successfully!", "success");
setBtnLoading(false);
});
};
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="mb-4">
<Button
type="button"
color="secondary"
size="sm"
loading={btnLoading}
StartIcon={ArrowLeftIcon}
onClick={() => props.onCloseEdit()}>
Back
</Button>
</div>
<div>
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-bold leading-6 text-gray-900">Manage your webhook</h3>
</div>
</div>
<hr className="mt-2" />
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateWebhookHandler}>
<div className="my-4">
<div className="mb-4">
<label htmlFor="subUrl" className="block text-sm font-medium text-gray-700">
Subscriber Url
</label>
<input
ref={subUrlRef}
type="text"
name="subUrl"
id="subUrl"
defaultValue={props.webhook.subscriberUrl || ""}
placeholder="https://example.com/sub"
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
<legend className="block pt-4 mb-2 text-sm font-medium text-gray-700"> Event Triggers </legend>
<div className="p-2 bg-white border border-gray-300 rounded-sm">
<div className="flex p-2">
<div className="w-10/12">
<h2 className="text-sm text-gray-800">Booking Created</h2>
</div>
<div className="flex items-center justify-end w-2/12 text-right">
<Switch
defaultChecked={bookingCreated}
onCheckedChange={() => {
setBookingCreated(!bookingCreated);
}}
/>
</div>
</div>
<div className="flex px-2 py-1">
<div className="w-10/12">
<h2 className="text-sm text-gray-800">Booking Rescheduled</h2>
</div>
<div className="flex items-center justify-end w-2/12 text-right">
<Switch
defaultChecked={bookingRescheduled}
onCheckedChange={() => {
setBookingRescheduled(!bookingRescheduled);
}}
/>
</div>
</div>
<div className="flex p-2">
<div className="w-10/12">
<h2 className="text-sm text-gray-800">Booking Cancelled</h2>
</div>
<div className="flex items-center justify-end w-2/12 text-right">
<Switch
defaultChecked={bookingCancelled}
onCheckedChange={() => {
setBookingCancelled(!bookingCancelled);
}}
/>
</div>
</div>
</div>
<legend className="block pt-4 mb-2 text-sm font-medium text-gray-700"> Webhook Status </legend>
<div className="p-2 bg-white border border-gray-300 rounded-sm">
<div className="flex p-2">
<div className="w-10/12">
<h2 className="text-sm text-gray-800">Webhook Enabled</h2>
</div>
<div className="flex items-center justify-end w-2/12 text-right">
<Switch
defaultChecked={webhookEnabled}
onCheckedChange={() => {
setWebhookEnabled(!webhookEnabled);
}}
/>
</div>
</div>
</div>
</div>
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ml-2" loading={btnLoading}>
Save
</Button>
</div>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { Webhook } from "@lib/webhook";
import WebhookListItem from "./WebhookListItem";
export default function WebhookList(props: {
webhooks: Webhook[];
onChange: () => void;
onEditWebhook: (webhook: Webhook) => void;
}) {
return (
<div>
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.webhooks.map((webhook: Webhook) => (
<WebhookListItem
onChange={props.onChange}
key={webhook.id}
webhook={webhook}
onEditWebhook={() => props.onEditWebhook(webhook)}></WebhookListItem>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,105 @@
import { TrashIcon, PencilAltIcon } from "@heroicons/react/outline";
import showToast from "@lib/notification";
import { Webhook } from "@lib/webhook";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Button from "@components/ui/Button";
export default function WebhookListItem(props: {
onChange: () => void;
key: number;
webhook: Webhook;
onEditWebhook: () => void;
}) {
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const deleteWebhook = (webhookId: string) => {
fetch("/api/webhooks/" + webhookId, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
})
.then(handleErrors)
.then(() => {
showToast("Webhook removed successfully!", "success");
props.onChange();
});
};
return (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex pr-2 border-r border-gray-100">
<span className="flex flex-col space-y-2 text-xs">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span key={ind} className="px-1 text-xs text-blue-700 rounded-md w-max bg-blue-50">
{eventTrigger}
</span>
))}
</span>
</div>
<div className="flex w-full">
<div className="self-center inline-block ml-3 space-y-1">
<span className="flex text-sm text-neutral-700">{props.webhook.subscriberUrl}</span>
</div>
</div>
<div className="flex">
{!props.webhook.active && (
<span className="self-center h-6 px-3 py-1 text-xs text-red-700 capitalize rounded-md bg-red-50">
Disabled
</span>
)}
{!!props.webhook.active && (
<span className="self-center h-6 px-3 py-1 text-xs text-green-700 capitalize rounded-md bg-green-50">
Enabled
</span>
)}
<Tooltip content="Edit Webhook">
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={PencilAltIcon}
className="self-center w-full p-2 ml-4"></Button>
</Tooltip>
<Dialog>
<Tooltip content="Delete Webhook">
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="minimal"
size="icon"
StartIcon={TrashIcon}
className="self-center w-full p-2 ml-2"></Button>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title="Delete Webhook"
confirmBtnText="Yes, delete webhook"
cancelBtnText="Cancel"
onConfirm={() => {
deleteWebhook(props.webhook.id);
}}>
Are you sure you want to delete this webhook? You will no longer receive Cal.com meeting data at
a specified URL, in real-time, when an event is scheduled or canceled .
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</li>
);
}

View File

@ -1,5 +1,6 @@
import { Credential } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import ICAL from "ical.js";
import { createEvent, DurationObject, Attendee, Person } from "ics";
import {
@ -19,6 +20,8 @@ import logger from "@lib/logger";
import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
import { stripHtml } from "../../emails/helpers";
dayjs.extend(utc);
const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
type EventBusyDate = Record<"start" | "end", Date>;

View File

@ -1,5 +1,6 @@
import { Credential } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import ICAL from "ical.js";
import { Attendee, createEvent, DurationObject, Person } from "ics";
import {
@ -19,6 +20,8 @@ import logger from "@lib/logger";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient";
import { stripHtml } from "../../emails/helpers";
dayjs.extend(utc);
const log = logger.getChildLogger({ prefix: ["[lib] caldav"] });
type EventBusyDate = Record<"start" | "end", Date>;

3
lib/webhook.ts Normal file
View File

@ -0,0 +1,3 @@
import { Webhook as PrismaWebhook } from "@prisma/client";
export type Webhook = PrismaWebhook & { prevState: null };

View File

@ -0,0 +1,33 @@
import { CalendarEvent } from "@lib/calendarClient";
const sendPayload = (
triggerEvent: string,
createdAt: string,
subscriberUrl: string,
payload: CalendarEvent
): Promise<string | Response> =>
new Promise((resolve, reject) => {
if (!subscriberUrl || !payload) {
return reject("Missing required elements to send webhook payload.");
}
fetch(subscriberUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: payload,
}),
})
.then((response) => {
resolve(response);
})
.catch((err) => {
reject(err);
});
});
export default sendPayload;

View File

@ -0,0 +1,27 @@
import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@lib/prisma";
const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise<string[]> => {
const allWebhooks = await prisma.webhook.findMany({
where: {
userId: userId,
AND: {
eventTriggers: {
has: triggerEvent,
},
active: {
equals: true,
},
},
},
select: {
subscriberUrl: true,
},
});
const subscriberUrls = allWebhooks.map(({ subscriberUrl }) => subscriberUrl);
return subscriberUrls;
};
export default getSubscriberUrls;

View File

@ -136,11 +136,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const openingHours = req.body.availability.openingHours || [];
// const overrides = req.body.availability.dateOverrides || [];
await prisma.availability.deleteMany({
where: {
eventTypeId: +req.body.id,
},
});
Promise.all(
openingHours.map((schedule) =>
prisma.availability.create({
@ -174,6 +169,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
await prisma.webhookEventTypes.deleteMany({
where: {
eventTypeId: req.body.id,
},
});
await prisma.eventType.delete({
where: {
id: req.body.id,

View File

@ -19,6 +19,8 @@ import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
import { getBusyVideoTimes } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
dayjs.extend(dayjsBusinessDays);
dayjs.extend(utc);
@ -464,6 +466,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.debug(`Booking ${user.username} completed`);
const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscriberUrls = await getSubscriberUrls(user.id, eventTrigger);
const promises = subscriberUrls.map((url) =>
sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e);
})
);
await Promise.all(promises);
await prisma.booking.update({
where: {
uid: booking.uid,

View File

@ -8,10 +8,12 @@ import { getSession } from "@lib/auth";
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
import prisma from "@lib/prisma";
import { deleteMeeting } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
export default async function handler(req, res) {
// just bail if it not a DELETE
if (req.method !== "DELETE") {
if (req.method !== "DELETE" && req.method !== "POST") {
return res.status(405).end();
}
@ -24,6 +26,7 @@ export default async function handler(req, res) {
},
select: {
id: true,
userId: true,
user: {
select: {
id: true,
@ -48,6 +51,7 @@ export default async function handler(req, res) {
startTime: true,
endTime: true,
uid: true,
eventTypeId: true,
},
});
@ -59,6 +63,41 @@ export default async function handler(req, res) {
return res.status(403).json({ message: "Cannot cancel past events" });
}
const organizer = await prisma.user.findFirst({
where: {
id: bookingToDelete.userId as number,
},
select: {
name: true,
email: true,
timeZone: true,
},
});
const evt: CalendarEvent = {
type: bookingToDelete?.title,
title: bookingToDelete?.title,
description: bookingToDelete?.description || "",
startTime: bookingToDelete?.startTime.toString(),
endTime: bookingToDelete?.endTime.toString(),
organizer: organizer,
attendees: bookingToDelete?.attendees.map((attendee) => {
const retObj = { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
return retObj;
}),
};
// Hook up the webhook logic here
const eventTrigger = "BOOKING_CANCELLED";
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberUrls = await getSubscriberUrls(bookingToDelete.userId, eventTrigger);
const promises = subscriberUrls.map((url) =>
sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e);
})
);
await Promise.all(promises);
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
await prisma.booking.update({

115
pages/api/eventType.ts Normal file
View File

@ -0,0 +1,115 @@
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
price: true,
currency: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
avatar: true,
completedOnboarding: true,
createdDate: true,
plan: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logo: true,
members: {
select: {
userId: true,
},
},
eventTypes: {
select: eventTypeSelect,
},
},
},
},
},
eventTypes: {
where: {
team: null,
},
select: eventTypeSelect,
},
},
});
// backwards compatibility, TMP:
const typesRaw = await prisma.eventType.findMany({
where: {
userId: session.user.id,
},
select: eventTypeSelect,
});
type EventTypeGroup = {
teamId?: number | null;
profile?: {
slug: typeof user["username"];
name: typeof user["name"];
image: typeof user["avatar"];
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
};
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id] || {};
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
...et,
$disabled: user.plan === "FREE" && index > 0,
}));
return res.status(200).json({ eventTypes: mergedEventTypes });
}

49
pages/api/webhook.ts Normal file
View File

@ -0,0 +1,49 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
dayjs.extend(utc);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
// List webhooks
if (req.method === "GET") {
const webhooks = await prisma.webhook.findMany({
where: {
userId: session.user.id,
},
});
return res.status(200).json({ webhooks: webhooks });
}
if (req.method === "POST") {
const translator = short();
const seed = `${req.body.subscriberUrl}:${dayjs(new Date()).utc().format()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
await prisma.webhook.create({
data: {
id: uid,
userId: session.user.id,
subscriberUrl: req.body.subscriberUrl,
eventTriggers: req.body.eventTriggers,
},
});
return res.status(201).json({ message: "Webhook created" });
}
res.status(404).json({ message: "Webhook not found" });
}

View File

@ -0,0 +1,57 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
// GET /api/webhook/{hook}
const webhooks = await prisma.webhook.findFirst({
where: {
id: String(req.query.hook),
userId: session.user.id,
},
});
if (req.method === "GET") {
return res.status(200).json({ webhooks: webhooks });
}
// DELETE /api/webhook/{hook}
if (req.method === "DELETE") {
await prisma.webhook.delete({
where: {
id: String(req.query.hook),
},
});
return res.status(200).json({});
}
if (req.method === "PATCH") {
const webhook = await prisma.webhook.findUnique({
where: {
id: req.query.hook as string,
},
});
if (!webhook) {
return res.status(404).json({ message: "Invalid Webhook" });
}
await prisma.webhook.update({
where: {
id: req.query.hook as string,
},
data: {
subscriberUrl: req.body.subscriberUrl,
eventTriggers: req.body.eventTriggers,
active: req.body.enabled,
},
});
return res.status(200).json({ message: "Webhook updated successfully" });
}
}

View File

@ -63,24 +63,24 @@ export default function Type(props) {
description={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`}
/>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
{error && (
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
<XIcon className="w-6 h-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{error}
</h3>
</div>
@ -89,11 +89,11 @@ export default function Type(props) {
{!error && (
<>
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
<XIcon className="w-6 h-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
{props.cancellationAllowed
? "Really cancel your booking?"
: "You cannot cancel this booking"}
@ -105,8 +105,8 @@ export default function Type(props) {
: "The event is in the past"}
</p>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="font-cal text-lg font-medium text-gray-600 mb-2">
<div className="py-4 mt-4 border-t border-b">
<h2 className="mb-2 text-lg font-medium text-gray-600 font-cal">
{props.booking.title}
</h2>
<p className="text-gray-500">
@ -119,7 +119,7 @@ export default function Type(props) {
</div>
</div>
{props.cancellationAllowed && (
<div className="mt-5 sm:mt-6 text-centerspace-x-2">
<div className="mt-5 space-x-2 text-center sm:mt-6">
<Button
color="secondary"
data-testid="cancel"
@ -156,6 +156,7 @@ export async function getServerSideProps(context) {
attendees: true,
user: {
select: {
id: true,
username: true,
name: true,
},

View File

@ -1,72 +1,287 @@
import { PlusIcon } from "@heroicons/react/outline";
import { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/client";
import { useEffect, useState, useRef } from "react";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Webhook } from "@lib/webhook";
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
import Loader from "@components/Loader";
import SettingsShell from "@components/SettingsShell";
import Shell from "@components/Shell";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
import EditWebhook from "@components/webhook/EditWebhook";
import WebhookList from "@components/webhook/WebhookList";
export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
const [, loading] = useSession();
const [isLoading, setLoading] = useState(false);
const [bookingCreated, setBookingCreated] = useState(true);
const [bookingRescheduled, setBookingRescheduled] = useState(true);
const [bookingCancelled, setBookingCancelled] = useState(true);
const [editWebhookEnabled, setEditWebhookEnabled] = useState(false);
const [webhooks, setWebhooks] = useState([]);
const [webhookToEdit, setWebhookToEdit] = useState<Webhook | null>();
const [webhookEventTrigger, setWebhookEventTriggers] = useState([
"BOOKING_CREATED",
"BOOKING_RESCHEDULED",
"BOOKING_CANCELLED",
]);
const subUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
useEffect(() => {
const arr = [];
bookingCreated && arr.push("BOOKING_CREATED");
bookingRescheduled && arr.push("BOOKING_RESCHEDULED");
bookingCancelled && arr.push("BOOKING_CANCELLED");
setWebhookEventTriggers(arr);
}, [bookingCreated, bookingRescheduled, bookingCancelled]);
useEffect(() => {
getWebhooks();
}, []);
if (loading) {
return <Loader />;
}
const iframeTemplate = `<iframe src="${process.env.NEXT_PUBLIC_APP_URL}/${props.user?.username}" frameborder="0" allowfullscreen></iframe>`;
const htmlTemplate = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Schedule a meeting</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body>${iframeTemplate}</body></html>`;
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const getWebhooks = () => {
fetch("/api/webhook")
.then(handleErrors)
.then((data) => {
setWebhooks(
data.webhooks.map((webhook: Webhook) => {
return {
...webhook,
eventTriggers: webhook.eventTriggers.map((eventTrigger: string) => eventTrigger.toLowerCase()),
};
})
);
console.log(data.webhooks);
})
.catch(console.log);
setLoading(false);
};
const createWebhook = () => {
setLoading(true);
fetch("/api/webhook", {
method: "POST",
body: JSON.stringify({
subscriberUrl: subUrlRef.current.value,
eventTriggers: webhookEventTrigger,
}),
headers: {
"Content-Type": "application/json",
},
})
.then(getWebhooks)
.catch(console.log);
};
const editWebhook = (webhook: Webhook) => {
setEditWebhookEnabled(true);
setWebhookToEdit(webhook);
};
const onCloseEdit = () => {
getWebhooks();
setEditWebhookEnabled(false);
};
return (
<Shell heading="Embed" subtitle="Integrate with your website using our embed options.">
<Shell
heading="Embed &amp; Webhooks"
subtitle="Integrate with your website using our embed options, or get real-time booking information using custom webhooks.">
<SettingsShell>
<div className="py-6 lg:pb-8 lg:col-span-9">
<div className="mb-6">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">iframe Embed</h2>
<p className="mt-1 text-sm text-gray-500">The easiest way to embed Cal.com on your website.</p>
</div>
<div className="grid grid-cols-2 space-x-4">
<div>
<label htmlFor="iframe" className="block text-sm font-medium text-gray-700">
Standard iframe
</label>
<div className="mt-1">
<textarea
id="iframe"
className="h-32 shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Loading..."
defaultValue={iframeTemplate}
readOnly
/>
{!editWebhookEnabled && (
<div className="py-6 lg:pb-8 lg:col-span-9">
<div className="mb-6">
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">iframe Embed</h2>
<p className="mt-1 text-sm text-gray-500">The easiest way to embed Cal.com on your website.</p>
</div>
<div className="grid grid-cols-2 space-x-4">
<div>
<label htmlFor="iframe" className="block text-sm font-medium text-gray-700">
Standard iframe
</label>
<div className="mt-1">
<textarea
id="iframe"
className="block w-full h-32 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-black sm:text-sm"
placeholder="Loading..."
defaultValue={iframeTemplate}
readOnly
/>
</div>
</div>
<div>
<label htmlFor="fullscreen" className="block text-sm font-medium text-gray-700">
Responsive full screen iframe
</label>
<div className="mt-1">
<textarea
id="fullscreen"
className="block w-full h-32 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-black sm:text-sm"
placeholder="Loading..."
defaultValue={htmlTemplate}
readOnly
/>
</div>
</div>
</div>
<div>
<label htmlFor="fullscreen" className="block text-sm font-medium text-gray-700">
Responsive full screen iframe
</label>
<div className="mt-1">
<textarea
id="fullscreen"
className="h-32 shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Loading..."
defaultValue={htmlTemplate}
readOnly
/>
<hr className="mt-8" />
<div className="flex justify-between my-6">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">Webhooks</h2>
<p className="mt-1 text-sm text-gray-500">
Receive Cal meeting data at a specified URL, in real-time, when an event is scheduled or
cancelled.{" "}
</p>
</div>
<div>
<Dialog>
<DialogTrigger className="px-4 py-2 my-6 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
<PlusIcon className="inline w-5 h-5 mr-1" />
New Webhook
</DialogTrigger>
<DialogContent>
<DialogHeader
title="Create a new webhook"
subtitle="Create a new webhook to your account"
/>
<div className="my-4">
<div className="mb-4">
<label htmlFor="subUrl" className="block text-sm font-medium text-gray-700">
Subscriber Url
</label>
<input
ref={subUrlRef}
type="text"
name="subUrl"
id="subUrl"
placeholder="https://example.com/sub"
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
<legend className="block pt-4 mb-2 text-sm font-medium text-gray-700">
{" "}
Event Triggers{" "}
</legend>
<div className="p-2 border border-gray-300 rounded-sm">
<div className="flex pb-4">
<div className="w-10/12">
<h2 className="font-medium text-gray-800">Booking Created</h2>
</div>
<div className="flex items-center justify-center w-2/12 text-right">
<Switch
defaultChecked={true}
id="booking-created"
value={bookingCreated}
onCheckedChange={() => {
setBookingCreated(!bookingCreated);
}}
/>
</div>
</div>
<div className="flex py-1">
<div className="w-10/12">
<h2 className="font-medium text-gray-800">Booking Rescheduled</h2>
</div>
<div className="flex items-center justify-center w-2/12 text-right">
<Switch
defaultChecked={true}
id="booking-rescheduled"
value={bookingRescheduled}
onCheckedChange={() => {
setBookingRescheduled(!bookingRescheduled);
}}
/>
</div>
</div>
<div className="flex pt-4">
<div className="w-10/12">
<h2 className="font-medium text-gray-800">Booking Cancelled</h2>
</div>
<div className="flex items-center justify-center w-2/12 text-right">
<Switch
defaultChecked={true}
id="booking-cancelled"
value={bookingCancelled}
onCheckedChange={() => {
setBookingCancelled(!bookingCancelled);
}}
/>
</div>
</div>
</div>
</div>
<div className="gap-2 mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button
type="button"
loading={isLoading}
onClick={createWebhook}
color="primary"
className="ml-2">
Create Webhook
</Button>
</DialogClose>
<DialogClose asChild>
<Button color="secondary">Cancel</Button>
</DialogClose>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="flex flex-col justify-between md:flex-row">
<div></div>
</div>
<div>
{!!webhooks.length && (
<WebhookList
webhooks={webhooks}
onChange={getWebhooks}
onEditWebhook={editWebhook}></WebhookList>
)}
</div>
</div>
</div>
<hr className="mt-8" />
<div className="my-6">
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">Cal.com API</h2>
<p className="mt-1 text-sm text-gray-500">
Leverage our API for full control and customizability.
</p>
</div>
<a href="https://developer.cal.com/api" className="btn btn-primary">
Browse our API documentation
</a>
</div>
<div className="my-6">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">Cal.com API</h2>
<p className="mt-1 text-sm text-gray-500">
Leverage our API for full control and customizability.
</p>
</div>
<a href="https://developer.cal.com/api" className="btn btn-primary">
Browse our API documentation
</a>
</div>
)}
{!!editWebhookEnabled && <EditWebhook webhook={webhookToEdit} onCloseEdit={onCloseEdit} />}
</SettingsShell>
</Shell>
);
@ -80,7 +295,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
email: session?.user?.email,
},
select: {
id: true,

View File

@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "WebhookTriggerEvents" AS ENUM ('BOOKING_CREATED', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED');
-- CreateTable
CREATE TABLE "Webhook" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"subscriberUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"active" BOOLEAN NOT NULL DEFAULT true,
"eventTriggers" "WebhookTriggerEvents"[],
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Webhook.id_unique" ON "Webhook"("id");

View File

@ -275,3 +275,18 @@ model Payment {
data Json
externalId String @unique
}
enum WebhookTriggerEvents {
BOOKING_CREATED
BOOKING_RESCHEDULED
BOOKING_CANCELLED
}
model Webhook {
id String @unique @id
userId Int
subscriberUrl String
createdAt DateTime @default(now())
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
}