Merge branch 'main' into enable-hard-mode
commit
b592b2f295
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
@ -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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}))
|
|
@ -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(),
|
||||
}))
|
Loading…
Reference in New Issue