diff --git a/components/SettingsShell.tsx b/components/SettingsShell.tsx index 64a72b0b..04c91170 100644 --- a/components/SettingsShell.tsx +++ b/components/SettingsShell.tsx @@ -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", diff --git a/components/webhook/EditWebhook.tsx b/components/webhook/EditWebhook.tsx new file mode 100644 index 00000000..56ea9fc9 --- /dev/null +++ b/components/webhook/EditWebhook.tsx @@ -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() as React.MutableRefObject; + + 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 ( +
+
+
+ +
+
+
+

Manage your webhook

+
+
+
+
+
+
+ + + Event Triggers +
+
+
+

Booking Created

+
+
+ { + setBookingCreated(!bookingCreated); + }} + /> +
+
+
+
+

Booking Rescheduled

+
+
+ { + setBookingRescheduled(!bookingRescheduled); + }} + /> +
+
+
+
+

Booking Cancelled

+
+
+ { + setBookingCancelled(!bookingCancelled); + }} + /> +
+
+
+ Webhook Status +
+
+
+

Webhook Enabled

+
+
+ { + setWebhookEnabled(!webhookEnabled); + }} + /> +
+
+
+
+
+ +
+
+
+
+
+ ); +} diff --git a/components/webhook/WebhookList.tsx b/components/webhook/WebhookList.tsx new file mode 100644 index 00000000..a046a039 --- /dev/null +++ b/components/webhook/WebhookList.tsx @@ -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 ( +
+
    + {props.webhooks.map((webhook: Webhook) => ( + props.onEditWebhook(webhook)}> + ))} +
+
+ ); +} diff --git a/components/webhook/WebhookListItem.tsx b/components/webhook/WebhookListItem.tsx new file mode 100644 index 00000000..4357255a --- /dev/null +++ b/components/webhook/WebhookListItem.tsx @@ -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 ( +
  • +
    +
    + + {props.webhook.eventTriggers.map((eventTrigger, ind) => ( + + {eventTrigger} + + ))} + +
    +
    +
    + {props.webhook.subscriberUrl} +
    +
    +
    + {!props.webhook.active && ( + + Disabled + + )} + {!!props.webhook.active && ( + + Enabled + + )} + + + + + + + + + + + { + 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 . + + +
    +
    +
  • + ); +} diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts index 1429b0c4..a619e820 100644 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -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>; diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts index c9027e83..633b5a02 100644 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -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>; diff --git a/lib/webhook.ts b/lib/webhook.ts new file mode 100644 index 00000000..91ee55f5 --- /dev/null +++ b/lib/webhook.ts @@ -0,0 +1,3 @@ +import { Webhook as PrismaWebhook } from "@prisma/client"; + +export type Webhook = PrismaWebhook & { prevState: null }; diff --git a/lib/webhooks/sendPayload.tsx b/lib/webhooks/sendPayload.tsx new file mode 100644 index 00000000..9a64b9b5 --- /dev/null +++ b/lib/webhooks/sendPayload.tsx @@ -0,0 +1,33 @@ +import { CalendarEvent } from "@lib/calendarClient"; + +const sendPayload = ( + triggerEvent: string, + createdAt: string, + subscriberUrl: string, + payload: CalendarEvent +): Promise => + 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; diff --git a/lib/webhooks/subscriberUrls.tsx b/lib/webhooks/subscriberUrls.tsx new file mode 100644 index 00000000..aeab311c --- /dev/null +++ b/lib/webhooks/subscriberUrls.tsx @@ -0,0 +1,27 @@ +import { WebhookTriggerEvents } from "@prisma/client"; + +import prisma from "@lib/prisma"; + +const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise => { + 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; diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index e5966b4f..f68dca3c 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -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, diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 954cff23..89e03040 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -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, diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index bd796e2b..91a544e3 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -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({ diff --git a/pages/api/eventType.ts b/pages/api/eventType.ts new file mode 100644 index 00000000..66f5ef9b --- /dev/null +++ b/pages/api/eventType.ts @@ -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()({ + 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); + const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({ + ...et, + $disabled: user.plan === "FREE" && index > 0, + })); + + return res.status(200).json({ eventTypes: mergedEventTypes }); +} diff --git a/pages/api/webhook.ts b/pages/api/webhook.ts new file mode 100644 index 00000000..ec4d7863 --- /dev/null +++ b/pages/api/webhook.ts @@ -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" }); +} diff --git a/pages/api/webhooks/[hook]/index.ts b/pages/api/webhooks/[hook]/index.ts new file mode 100644 index 00000000..02861ac2 --- /dev/null +++ b/pages/api/webhooks/[hook]/index.ts @@ -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" }); + } +} diff --git a/pages/cancel/[uid].tsx b/pages/cancel/[uid].tsx index 0d14bd4d..46746850 100644 --- a/pages/cancel/[uid].tsx +++ b/pages/cancel/[uid].tsx @@ -63,24 +63,24 @@ export default function Type(props) { description={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`} />
    -
    -
    -