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>
This commit is contained in:
parent
785058558c
commit
4c07faefe7
19 changed files with 956 additions and 62 deletions
|
@ -15,7 +15,7 @@ export default function SettingsShell({ children }: { children: React.ReactNode
|
||||||
href: "/settings/security",
|
href: "/settings/security",
|
||||||
icon: KeyIcon,
|
icon: KeyIcon,
|
||||||
},
|
},
|
||||||
{ name: "Embed", href: "/settings/embed", icon: CodeIcon },
|
{ name: "Embed & Webhooks", href: "/settings/embed", icon: CodeIcon },
|
||||||
{
|
{
|
||||||
name: "Teams",
|
name: "Teams",
|
||||||
href: "/settings/teams",
|
href: "/settings/teams",
|
||||||
|
|
176
components/webhook/EditWebhook.tsx
Normal file
176
components/webhook/EditWebhook.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
23
components/webhook/WebhookList.tsx
Normal file
23
components/webhook/WebhookList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
105
components/webhook/WebhookListItem.tsx
Normal file
105
components/webhook/WebhookListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import { createEvent, DurationObject, Attendee, Person } from "ics";
|
import { createEvent, DurationObject, Attendee, Person } from "ics";
|
||||||
import {
|
import {
|
||||||
|
@ -19,6 +20,8 @@ import logger from "@lib/logger";
|
||||||
import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
|
import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
|
||||||
import { stripHtml } from "../../emails/helpers";
|
import { stripHtml } from "../../emails/helpers";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
|
const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
|
||||||
|
|
||||||
type EventBusyDate = Record<"start" | "end", Date>;
|
type EventBusyDate = Record<"start" | "end", Date>;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import { Attendee, createEvent, DurationObject, Person } from "ics";
|
import { Attendee, createEvent, DurationObject, Person } from "ics";
|
||||||
import {
|
import {
|
||||||
|
@ -19,6 +20,8 @@ import logger from "@lib/logger";
|
||||||
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient";
|
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient";
|
||||||
import { stripHtml } from "../../emails/helpers";
|
import { stripHtml } from "../../emails/helpers";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] caldav"] });
|
const log = logger.getChildLogger({ prefix: ["[lib] caldav"] });
|
||||||
|
|
||||||
type EventBusyDate = Record<"start" | "end", Date>;
|
type EventBusyDate = Record<"start" | "end", Date>;
|
||||||
|
|
3
lib/webhook.ts
Normal file
3
lib/webhook.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { Webhook as PrismaWebhook } from "@prisma/client";
|
||||||
|
|
||||||
|
export type Webhook = PrismaWebhook & { prevState: null };
|
33
lib/webhooks/sendPayload.tsx
Normal file
33
lib/webhooks/sendPayload.tsx
Normal 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;
|
27
lib/webhooks/subscriberUrls.tsx
Normal file
27
lib/webhooks/subscriberUrls.tsx
Normal 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;
|
|
@ -136,11 +136,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const openingHours = req.body.availability.openingHours || [];
|
const openingHours = req.body.availability.openingHours || [];
|
||||||
// const overrides = req.body.availability.dateOverrides || [];
|
// const overrides = req.body.availability.dateOverrides || [];
|
||||||
|
|
||||||
await prisma.availability.deleteMany({
|
|
||||||
where: {
|
|
||||||
eventTypeId: +req.body.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
openingHours.map((schedule) =>
|
openingHours.map((schedule) =>
|
||||||
prisma.availability.create({
|
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({
|
await prisma.eventType.delete({
|
||||||
where: {
|
where: {
|
||||||
id: req.body.id,
|
id: req.body.id,
|
||||||
|
|
|
@ -19,6 +19,8 @@ import logger from "@lib/logger";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { BookingCreateBody } from "@lib/types/booking";
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
import { getBusyVideoTimes } from "@lib/videoClient";
|
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
|
import sendPayload from "@lib/webhooks/sendPayload";
|
||||||
|
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessDays);
|
dayjs.extend(dayjsBusinessDays);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
@ -464,6 +466,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
log.debug(`Booking ${user.username} completed`);
|
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({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
uid: booking.uid,
|
uid: booking.uid,
|
||||||
|
|
|
@ -8,10 +8,12 @@ import { getSession } from "@lib/auth";
|
||||||
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
|
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { deleteMeeting } from "@lib/videoClient";
|
import { deleteMeeting } from "@lib/videoClient";
|
||||||
|
import sendPayload from "@lib/webhooks/sendPayload";
|
||||||
|
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
// just bail if it not a DELETE
|
// just bail if it not a DELETE
|
||||||
if (req.method !== "DELETE") {
|
if (req.method !== "DELETE" && req.method !== "POST") {
|
||||||
return res.status(405).end();
|
return res.status(405).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,6 +26,7 @@ export default async function handler(req, res) {
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -48,6 +51,7 @@ export default async function handler(req, res) {
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
uid: 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" });
|
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
|
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
|
||||||
// action always succeeds even if subsequent integrations fail cancellation.
|
// action always succeeds even if subsequent integrations fail cancellation.
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
|
|
115
pages/api/eventType.ts
Normal file
115
pages/api/eventType.ts
Normal 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
49
pages/api/webhook.ts
Normal 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" });
|
||||||
|
}
|
57
pages/api/webhooks/[hook]/index.ts
Normal file
57
pages/api/webhooks/[hook]/index.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
|
@ -63,24 +63,24 @@ export default function Type(props) {
|
||||||
description={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`}
|
description={`Cancel ${props.booking && props.booking.title} | ${props.profile.name}`}
|
||||||
/>
|
/>
|
||||||
<main className="max-w-3xl mx-auto my-24">
|
<main className="max-w-3xl mx-auto my-24">
|
||||||
<div className="fixed z-50 inset-0 overflow-y-auto">
|
<div className="fixed inset-0 z-50 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="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 sm:my-0 transition-opacity" aria-hidden="true">
|
<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">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
​
|
​
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-headline">
|
aria-labelledby="modal-headline">
|
||||||
{error && (
|
{error && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
|
||||||
<XIcon className="h-6 w-6 text-red-600" />
|
<XIcon className="w-6 h-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<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}
|
{error}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,11 +89,11 @@ export default function Type(props) {
|
||||||
{!error && (
|
{!error && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
|
||||||
<XIcon className="h-6 w-6 text-red-600" />
|
<XIcon className="w-6 h-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<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
|
{props.cancellationAllowed
|
||||||
? "Really cancel your booking?"
|
? "Really cancel your booking?"
|
||||||
: "You cannot cancel this booking"}
|
: "You cannot cancel this booking"}
|
||||||
|
@ -105,8 +105,8 @@ export default function Type(props) {
|
||||||
: "The event is in the past"}
|
: "The event is in the past"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 border-t border-b py-4">
|
<div className="py-4 mt-4 border-t border-b">
|
||||||
<h2 className="font-cal text-lg font-medium text-gray-600 mb-2">
|
<h2 className="mb-2 text-lg font-medium text-gray-600 font-cal">
|
||||||
{props.booking.title}
|
{props.booking.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
|
@ -119,7 +119,7 @@ export default function Type(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.cancellationAllowed && (
|
{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
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
data-testid="cancel"
|
data-testid="cancel"
|
||||||
|
@ -156,6 +156,7 @@ export async function getServerSideProps(context) {
|
||||||
attendees: true,
|
attendees: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,30 +1,119 @@
|
||||||
|
import { PlusIcon } from "@heroicons/react/outline";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useSession } from "next-auth/client";
|
import { useSession } from "next-auth/client";
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
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 Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
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>) {
|
export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const [, loading] = useSession();
|
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) {
|
if (loading) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iframeTemplate = `<iframe src="${process.env.NEXT_PUBLIC_APP_URL}/${props.user?.username}" frameborder="0" allowfullscreen></iframe>`;
|
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 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 (
|
return (
|
||||||
<Shell heading="Embed" subtitle="Integrate with your website using our embed options.">
|
<Shell
|
||||||
|
heading="Embed & Webhooks"
|
||||||
|
subtitle="Integrate with your website using our embed options, or get real-time booking information using custom webhooks.">
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
|
{!editWebhookEnabled && (
|
||||||
<div className="py-6 lg:pb-8 lg:col-span-9">
|
<div className="py-6 lg:pb-8 lg:col-span-9">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">iframe Embed</h2>
|
<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>
|
<p className="mt-1 text-sm text-gray-500">The easiest way to embed Cal.com on your website.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 space-x-4">
|
<div className="grid grid-cols-2 space-x-4">
|
||||||
|
@ -35,7 +124,7 @@ export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<textarea
|
<textarea
|
||||||
id="iframe"
|
id="iframe"
|
||||||
className="h-32 shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
|
className="block w-full h-32 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-black sm:text-sm"
|
||||||
placeholder="Loading..."
|
placeholder="Loading..."
|
||||||
defaultValue={iframeTemplate}
|
defaultValue={iframeTemplate}
|
||||||
readOnly
|
readOnly
|
||||||
|
@ -49,7 +138,7 @@ export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<textarea
|
<textarea
|
||||||
id="fullscreen"
|
id="fullscreen"
|
||||||
className="h-32 shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
|
className="block w-full h-32 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-black sm:text-sm"
|
||||||
placeholder="Loading..."
|
placeholder="Loading..."
|
||||||
defaultValue={htmlTemplate}
|
defaultValue={htmlTemplate}
|
||||||
readOnly
|
readOnly
|
||||||
|
@ -57,8 +146,132 @@ export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div className="my-6">
|
||||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">Cal.com API</h2>
|
<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">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Leverage our API for full control and customizability.
|
Leverage our API for full control and customizability.
|
||||||
</p>
|
</p>
|
||||||
|
@ -67,6 +280,8 @@ export default function Embed(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
Browse our API documentation
|
Browse our API documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!!editWebhookEnabled && <EditWebhook webhook={webhookToEdit} onCloseEdit={onCloseEdit} />}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
|
@ -80,7 +295,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: session.user.email,
|
email: session?.user?.email,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -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");
|
|
@ -275,3 +275,18 @@ model Payment {
|
||||||
data Json
|
data Json
|
||||||
externalId String @unique
|
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[]
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue