Merge branch 'main' into enable-hard-mode

enable-hard-mode
kodiakhq[bot] 2022-03-02 15:32:00 +00:00 committed by GitHub
commit b592b2f295
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1448 additions and 1173 deletions

View File

@ -0,0 +1,162 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
import { DialogFooter } from "@components/Dialog";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@components/form/fields";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
import { TWebhook } from "@components/webhook/WebhookListItem";
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
export default function WebhookDialogForm(props: {
eventTypeId?: number;
defaultValues?: TWebhook;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const handleSubscriberUrlChange = (e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
};
const {
defaultValues = {
id: "",
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
subscriberUrl: "",
active: true,
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const form = useForm({
defaultValues,
});
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
const e = { ...event, eventTypeId: props.eventTypeId };
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<input type="hidden" {...form.register("id")} />
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={handleSubscriberUrlChange}
/>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}

View File

@ -0,0 +1,105 @@
import classNames from "classnames";
import Image from "next/image";
import { useState } from "react";
import { QueryCell } from "@lib/QueryCell";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import { Dialog, DialogContent } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import { ShellSubHeading } from "@components/Shell";
import Button from "@components/ui/Button";
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
export type WebhookListContainerType = {
eventTypeId?: number;
};
export default function WebhookListContainer(props: WebhookListContainerType) {
const { t } = useLocale();
const query = props.eventTypeId
? trpc.useQuery(["viewer.webhook.list", { eventTypeId: props.eventTypeId }], {
suspense: true,
})
: trpc.useQuery(["viewer.webhook.list"], {
suspense: true,
});
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading
className="mt-10"
title={t("Team Webhooks")}
subtitle={t("receive_cal_event_meeting_data")}
/>
<List>
<ListItem className={classNames("flex-col")}>
<div
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">{t("automation")}</ListItemText>
</div>
<div>
<Button
color="secondary"
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{data.length ? (
<List>
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm
eventTypeId={props.eventTypeId}
handleClose={() => setNewWebhookModal(false)}
/>
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
eventTypeId={props.eventTypeId || undefined}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
)}
/>
);
}

View File

@ -0,0 +1,93 @@
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Button from "@components/ui/Button";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className="-mt-px flex w-full p-4">
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-200"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs ",
props.webhook.active ? "bg-blue-100 text-blue-700" : "bg-blue-50 text-blue-200"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={PencilAltIcon}
className="ml-4 w-full self-center p-2"></Button>
</Tooltip>
<Dialog>
<Tooltip content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="minimal"
size="icon"
StartIcon={TrashIcon}
className="ml-2 w-full self-center p-2"></Button>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() =>
deleteWebhook.mutate({
id: props.webhook.id,
eventTypeId: props.webhook.eventTypeId || undefined,
})
}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}

View File

@ -0,0 +1,64 @@
import { ChevronRightIcon, SwitchHorizontalIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { InputGroupBox } from "@components/form/fields";
import Button from "@components/ui/Button";
export default function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className={"flex w-full cursor-pointer"}>
<ChevronRightIcon className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
StartIcon={SwitchHorizontalIcon}
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}

View File

@ -0,0 +1,28 @@
const supportedWebhookIntegrationList = ["https://discord.com/api/webhooks/"];
type WebhookIntegrationProps = {
url: string;
};
export const hasTemplateIntegration = (props: WebhookIntegrationProps) => {
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return props.url.includes(integration);
});
return ind > -1 ? true : false;
};
const customTemplate = (props: WebhookIntegrationProps) => {
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return props.url.includes(integration);
});
return integrationTemplate(supportedWebhookIntegrationList[ind]) || "";
};
const integrationTemplate = (webhookIntegration: string) => {
switch (webhookIntegration) {
case "https://discord.com/api/webhooks/":
return '{"content": "A new event has been scheduled","embeds": [{"color": 2697513,"fields": [{"name": "What","value": "{{title}} ({{type}})"},{"name": "When","value": "Start: {{startTime}} \\n End: {{endTime}} \\n Timezone: ({{organizer.timeZone}})"},{"name": "Who","value": "Organizer: {{organizer.name}} ({{organizer.email}}) \\n Booker: {{attendees.0.name}} ({{attendees.0.email}})" },{"name":"Description", "value":": {{description}}"},{"name":"Where","value":": {{location}} "}]}]}';
}
};
export default customTemplate;

View File

@ -2,13 +2,27 @@ import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@lib/prisma";
const getSubscribers = async (userId: number, triggerEvent: WebhookTriggerEvents) => {
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
triggerEvent: WebhookTriggerEvents;
};
const getSubscribers = async (options: GetSubscriberOptions) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
userId: userId,
OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: {
eventTriggers: {
has: triggerEvent,
has: options.triggerEvent,
},
active: {
equals: true,

View File

@ -1,4 +1,4 @@
import { Credential, Prisma, SchedulingType } from "@prisma/client";
import { Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@ -594,9 +594,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.debug(`Booking ${user.username} completed`);
const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
const eventTrigger: WebhookTriggerEvents = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
const subscriberOptions = {
userId: user.id,
eventTypeId,
triggerEvent: eventTrigger,
};
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getSubscribers(user.id, eventTrigger);
const subscribers = await getSubscribers(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,

View File

@ -1,4 +1,4 @@
import { BookingStatus } from "@prisma/client";
import { BookingStatus, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
@ -130,9 +130,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
// Hook up the webhook logic here
const eventTrigger = "BOOKING_CANCELLED";
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscribers = await getSubscribers(bookingToDelete.userId, eventTrigger);
const subscriberOptions = {
userId: bookingToDelete.userId,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
};
const subscribers = await getSubscribers(subscriberOptions);
const promises = subscribers.map((sub) =>
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
(e) => {

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,6 @@
import { ChevronRightIcon, PencilAltIcon, SwitchHorizontalIcon, TrashIcon } from "@heroicons/react/outline";
import { ClipboardIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import Image from "next/image";
import React, { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { JSONObject } from "superjson/dist/types";
import { QueryCell } from "@lib/QueryCell";
@ -11,17 +8,12 @@ import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import { trpc } from "@lib/trpc";
import { ClientSuspense } from "@components/ClientSuspense";
import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import Loader from "@components/Loader";
import Shell, { ShellSubHeading } from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { FieldsetLegend, Form, InputGroupBox, TextField, TextArea } from "@components/form/fields";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import ConnectIntegration from "@components/integrations/ConnectIntegrations";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
@ -29,367 +21,7 @@ import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className="-mt-px flex w-full p-4">
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-200"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs ",
props.webhook.active ? "bg-blue-100 text-blue-700" : "bg-blue-50 text-blue-200"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={PencilAltIcon}
className="ml-4 w-full self-center p-2"></Button>
</Tooltip>
<Dialog>
<Tooltip content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="minimal"
size="icon"
StartIcon={TrashIcon}
className="ml-2 w-full self-center p-2"></Button>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() => deleteWebhook.mutate({ id: props.webhook.id })}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}
function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className={"flex w-full cursor-pointer"}>
<ChevronRightIcon className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
StartIcon={SwitchHorizontalIcon}
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}
function WebhookDialogForm(props: {
//
defaultValues?: TWebhook;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const supportedWebhookIntegrationList = ["https://discord.com/api/webhooks/"];
const handleSubscriberUrlChange = (e) => {
form.setValue("subscriberUrl", e.target.value);
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return e.target.value.includes(integration);
});
if (ind > -1) updateCustomTemplate(supportedWebhookIntegrationList[ind]);
};
const updateCustomTemplate = (webhookIntegration) => {
setUseCustomPayloadTemplate(true);
switch (webhookIntegration) {
case "https://discord.com/api/webhooks/":
form.setValue(
"payloadTemplate",
'{"content": "A new event has been scheduled","embeds": [{"color": 2697513,"fields": [{"name": "What","value": "{{title}} ({{type}})"},{"name": "When","value": "Start: {{startTime}} \\n End: {{endTime}} \\n Timezone: ({{organizer.timeZone}})"},{"name": "Who","value": "Organizer: {{organizer.name}} ({{organizer.email}}) \\n Booker: {{attendees.0.name}} ({{attendees.0.email}})" },{"name":"Description", "value":": {{description}}"},{"name":"Where","value":": {{location}} "}]}]}'
);
}
};
const {
defaultValues = {
id: "",
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
subscriberUrl: "",
active: true,
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const form = useForm({
defaultValues,
});
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", event);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", event);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<input type="hidden" {...form.register("id")} />
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={handleSubscriberUrlChange}
/>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}
function WebhookListContainer() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.webhook.list"], { suspense: true });
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading className="mt-10" title={t("Webhooks")} subtitle={t("receive_cal_meeting_data")} />
<List>
<ListItem className={classNames("flex-col")}>
<div
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">{t("automation")}</ListItemText>
</div>
<div>
<Button
color="secondary"
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{data.length ? (
<List>
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm handleClose={() => setNewWebhookModal(false)} />
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
)}
/>
);
}
import WebhookListContainer from "@components/webhook/WebhookListContainer";
function IframeEmbedContainer() {
const { t } = useLocale();

View File

@ -314,6 +314,7 @@
"create_new_webhook_to_account": "Create a new webhook to your account",
"new_webhook": "New Webhook",
"receive_cal_meeting_data": "Receive Cal meeting data at a specified URL, in real-time, when an event is scheduled or cancelled.",
"receive_cal_event_meeting_data": "Receive Cal meeting data at a specified URL, in real-time, when this event is scheduled or cancelled.",
"responsive_fullscreen_iframe": "Responsive full screen iframe",
"loading": "Loading...",
"standard_iframe": "Standard iframe",

View File

@ -10,7 +10,19 @@ import { getTranslation } from "@server/lib/i18n";
export const webhookRouter = createProtectedRouter()
.query("list", {
async resolve({ ctx }) {
input: z
.object({
eventTypeId: z.number().optional(),
})
.optional(),
async resolve({ ctx, input }) {
if (input?.eventTypeId) {
return await ctx.prisma.webhook.findMany({
where: {
eventTypeId: input.eventTypeId,
},
});
}
return await ctx.prisma.webhook.findMany({
where: {
userId: ctx.user.id,
@ -24,8 +36,17 @@ export const webhookRouter = createProtectedRouter()
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
active: z.boolean(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
if (input.eventTypeId) {
return await ctx.prisma.webhook.create({
data: {
id: v4(),
...input,
},
});
}
return await ctx.prisma.webhook.create({
data: {
id: v4(),
@ -42,17 +63,26 @@ export const webhookRouter = createProtectedRouter()
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
active: z.boolean().optional(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
const { id, ...data } = input;
const webhook = await ctx.prisma.webhook.findFirst({
where: {
userId: ctx.user.id,
id,
},
});
const webhook = input.eventTypeId
? await ctx.prisma.webhook.findFirst({
where: {
eventTypeId: input.eventTypeId,
id,
},
})
: await ctx.prisma.webhook.findFirst({
where: {
userId: ctx.user.id,
id,
},
});
if (!webhook) {
// user does not own this webhook
// team event doesn't own this webhook
return null;
}
return await ctx.prisma.webhook.update({
@ -66,23 +96,36 @@ export const webhookRouter = createProtectedRouter()
.mutation("delete", {
input: z.object({
id: z.string(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
const { id } = input;
await ctx.prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
webhooks: {
delete: {
id,
input.eventTypeId
? await ctx.prisma.eventType.update({
where: {
id: input.eventTypeId,
},
},
},
});
data: {
webhooks: {
delete: {
id,
},
},
},
})
: await ctx.prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
webhooks: {
delete: {
id,
},
},
},
});
return {
id,
};

View File

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "eventTypeId" INTEGER,
ALTER COLUMN "userId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,6 +46,7 @@ model EventType {
teamId Int?
bookings Booking[]
availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
@ -345,11 +346,13 @@ enum WebhookTriggerEvents {
model Webhook {
id String @id @unique
userId Int
userId Int?
eventTypeId Int?
subscriberUrl String
payloadTemplate String?
createdAt DateTime @default(now())
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
}

View File

@ -0,0 +1,65 @@
import * as z from "zod"
import * as imports from "../zod-utils"
import { PeriodType, SchedulingType } from "@prisma/client"
import { CompleteUser, UserModel, CompleteTeam, TeamModel, CompleteBooking, BookingModel, CompleteAvailability, AvailabilityModel, CompleteWebhook, WebhookModel, CompleteDestinationCalendar, DestinationCalendarModel, CompleteEventTypeCustomInput, EventTypeCustomInputModel, CompleteSchedule, ScheduleModel } from "./index"
// Helper schema for JSON fields
type Literal = boolean | number | string
type Json = Literal | { [key: string]: Json } | Json[]
const literalSchema = z.union([z.string(), z.number(), z.boolean()])
const jsonSchema: z.ZodSchema<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
export const _EventTypeModel = z.object({
id: z.number().int(),
title: z.string().nonempty(),
slug: imports.eventTypeSlug,
description: z.string().nullish(),
position: z.number().int(),
locations: imports.eventTypeLocations,
length: z.number().int(),
hidden: z.boolean(),
userId: z.number().int().nullish(),
teamId: z.number().int().nullish(),
eventName: z.string().nullish(),
timeZone: z.string().nullish(),
periodType: z.nativeEnum(PeriodType),
periodStartDate: z.date().nullish(),
periodEndDate: z.date().nullish(),
periodDays: z.number().int().nullish(),
periodCountCalendarDays: z.boolean().nullish(),
requiresConfirmation: z.boolean(),
disableGuests: z.boolean(),
minimumBookingNotice: z.number().int(),
schedulingType: z.nativeEnum(SchedulingType).nullish(),
price: z.number().int(),
currency: z.string(),
slotInterval: z.number().int().nullish(),
metadata: jsonSchema,
})
export interface CompleteEventType extends z.infer<typeof _EventTypeModel> {
users: CompleteUser[]
team?: CompleteTeam | null
bookings: CompleteBooking[]
availability: CompleteAvailability[]
webhooks: CompleteWebhook[]
destinationCalendar?: CompleteDestinationCalendar | null
customInputs: CompleteEventTypeCustomInput[]
Schedule: CompleteSchedule[]
}
/**
* EventTypeModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const EventTypeModel: z.ZodSchema<CompleteEventType> = z.lazy(() => _EventTypeModel.extend({
users: UserModel.array(),
team: TeamModel.nullish(),
bookings: BookingModel.array(),
availability: AvailabilityModel.array(),
webhooks: WebhookModel.array(),
destinationCalendar: DestinationCalendarModel.nullish(),
customInputs: EventTypeCustomInputModel.array(),
Schedule: ScheduleModel.array(),
}))

View File

@ -0,0 +1,30 @@
import * as z from "zod"
import * as imports from "../zod-utils"
import { WebhookTriggerEvents } from "@prisma/client"
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index"
export const _WebhookModel = z.object({
id: z.string(),
userId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
subscriberUrl: z.string(),
payloadTemplate: z.string().nullish(),
createdAt: z.date(),
active: z.boolean(),
eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(),
})
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
user?: CompleteUser | null
eventType?: CompleteEventType | null
}
/**
* WebhookModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
user: UserModel.nullish(),
eventType: EventTypeModel.nullish(),
}))