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
parent
785058558c
commit
4c07faefe7
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { Webhook as PrismaWebhook } from "@prisma/client";
|
||||
|
||||
export type Webhook = PrismaWebhook & { prevState: null };
|
|
@ -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;
|
|
@ -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 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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 });
|
||||
}
|
|
@ -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" });
|
||||
}
|
|
@ -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}`}
|
||||
/>
|
||||
<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">
|
||||
​
|
||||
</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,
|
||||
},
|
||||
|
|
|
@ -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 & 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,
|
||||
|
|
|
@ -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
|
||||
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 New Issue