Webhook tweaks + Support added for "Custom payload templates" / x-www-form-urlencoded / json (#1193)
* Changed styling of webhook test & updated <Form> component * Implements custom webhook formats Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>pull/1197/head
parent
ecc960f0a3
commit
5b3dd02747
|
@ -1,38 +1,63 @@
|
|||
import { compile } from "handlebars";
|
||||
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
|
||||
const sendPayload = (
|
||||
type ContentType = "application/json" | "application/x-www-form-urlencoded";
|
||||
|
||||
function applyTemplate(template: string, data: Omit<CalendarEvent, "language">, contentType: ContentType) {
|
||||
const compiled = compile(template)(data);
|
||||
if (contentType === "application/json") {
|
||||
return jsonParse(compiled);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function jsonParse(jsonString: string) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
// don't do anything.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const sendPayload = async (
|
||||
triggerEvent: string,
|
||||
createdAt: string,
|
||||
subscriberUrl: string,
|
||||
payload: CalendarEvent
|
||||
): Promise<string | Response> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!subscriberUrl || !payload) {
|
||||
return reject(new Error("Missing required elements to send webhook payload."));
|
||||
}
|
||||
const body = {
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: payload,
|
||||
};
|
||||
data: Omit<CalendarEvent, "language">,
|
||||
template?: string | null
|
||||
) => {
|
||||
if (!subscriberUrl || !data) {
|
||||
throw new Error("Missing required elements to send webhook payload.");
|
||||
}
|
||||
|
||||
fetch(subscriberUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
reject(new Error(`Response code ${response.status}`));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
const contentType =
|
||||
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
|
||||
|
||||
const body = template
|
||||
? applyTemplate(template, data, contentType)
|
||||
: JSON.stringify({
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
|
||||
const response = await fetch(subscriberUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
message: text,
|
||||
};
|
||||
};
|
||||
|
||||
export default sendPayload;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { WebhookTriggerEvents } from "@prisma/client";
|
|||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise<string[]> => {
|
||||
const getSubscribers = async (userId: number, triggerEvent: WebhookTriggerEvents) => {
|
||||
const allWebhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
|
@ -17,11 +17,11 @@ const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEve
|
|||
},
|
||||
select: {
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
},
|
||||
});
|
||||
const subscriberUrls = allWebhooks.map(({ subscriberUrl }) => subscriberUrl);
|
||||
|
||||
return subscriberUrls;
|
||||
return allWebhooks;
|
||||
};
|
||||
|
||||
export default getSubscriberUrls;
|
||||
export default getSubscribers;
|
|
@ -21,7 +21,7 @@ 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";
|
||||
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
@ -494,12 +494,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
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 subscribers = await getSubscribers(user.id, eventTrigger);
|
||||
console.log("evt:", evt);
|
||||
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);
|
||||
})
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
|
||||
(e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
}
|
||||
)
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdap
|
|||
import prisma from "@lib/prisma";
|
||||
import { deleteMeeting } from "@lib/videoClient";
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
||||
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
@ -107,11 +107,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
// 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);
|
||||
})
|
||||
const subscribers = await getSubscribers(bookingToDelete.userId, eventTrigger);
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
|
||||
(e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
}
|
||||
)
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
|
|
|
@ -1,10 +1,4 @@
|
|||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilAltIcon,
|
||||
SwitchHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
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";
|
||||
|
@ -13,7 +7,6 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
|||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
@ -61,7 +54,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex mt-2">
|
||||
<span className="flex flex-col space-y-1 sm:space-y-0 text-xs sm:flex-row sm:space-x-2">
|
||||
<span className="flex flex-col space-y-1 text-xs sm:space-y-0 sm:flex-row sm:space-x-2">
|
||||
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
|
||||
<span
|
||||
key={ind}
|
||||
|
@ -114,6 +107,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }
|
|||
|
||||
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", {
|
||||
|
@ -124,13 +118,9 @@ function WebhookTestDisclosure() {
|
|||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
|
||||
<CollapsibleTrigger type="button" className={"cursor-pointer flex w-full text-sm"}>
|
||||
{t("webhook_test")}{" "}
|
||||
{open ? (
|
||||
<ChevronUpIcon className="w-5 h-5 text-gray-700" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-700" />
|
||||
)}
|
||||
<CollapsibleTrigger type="button" className={"cursor-pointer flex w-full"}>
|
||||
<ChevronRightIcon className={`${open ? "transform rotate-90" : ""} w-5 h-5 text-neutral-500`} />
|
||||
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InputGroupBox className="px-0 space-y-0 border-0">
|
||||
|
@ -141,7 +131,7 @@ function WebhookTestDisclosure() {
|
|||
type="button"
|
||||
color="minimal"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING" })}>
|
||||
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
|
||||
{t("ping_test")}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -152,9 +142,9 @@ function WebhookTestDisclosure() {
|
|||
<div
|
||||
className={classNames(
|
||||
"px-2 py-1 w-max text-xs ml-auto",
|
||||
mutation.data.status === 200 ? "text-green-500 bg-green-50" : "text-red-500 bg-red-50"
|
||||
mutation.data.ok ? "text-green-500 bg-green-50" : "text-red-500 bg-red-50"
|
||||
)}>
|
||||
{mutation.data.status === 200 ? t("success") : t("failed")}
|
||||
{mutation.data.ok ? t("success") : t("failed")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
|
||||
</>
|
||||
|
@ -180,9 +170,12 @@ function WebhookDialogForm(props: {
|
|||
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,
|
||||
});
|
||||
|
@ -190,24 +183,20 @@ function WebhookDialogForm(props: {
|
|||
<Form
|
||||
data-testid="WebhookDialogForm"
|
||||
form={form}
|
||||
onSubmit={(event) => {
|
||||
form
|
||||
.handleSubmit(async (values) => {
|
||||
if (values.id) {
|
||||
await utils.client.mutation("viewer.webhook.edit", values);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
} else {
|
||||
await utils.client.mutation("viewer.webhook.create", values);
|
||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
}
|
||||
|
||||
props.handleClose();
|
||||
})(event)
|
||||
.catch((err) => {
|
||||
showToast(`${getErrorFromUnknown(err).message}`, "error");
|
||||
});
|
||||
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")} />
|
||||
|
@ -256,6 +245,38 @@ function WebhookDialogForm(props: {
|
|||
))}
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
|
||||
<div className="space-x-3 text-sm">
|
||||
<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")}
|
||||
className="block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||
rows={5}
|
||||
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}></textarea>
|
||||
)}
|
||||
</fieldset>
|
||||
<WebhookTestDisclosure />
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "payloadTemplate" TEXT;
|
|
@ -100,7 +100,7 @@ model User {
|
|||
plan UserPlan @default(PRO)
|
||||
Schedule Schedule[]
|
||||
webhooks Webhook[]
|
||||
brandColor String @default("#292929")
|
||||
brandColor String @default("#292929")
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
@ -301,11 +301,12 @@ enum WebhookTriggerEvents {
|
|||
}
|
||||
|
||||
model Webhook {
|
||||
id String @id @unique
|
||||
userId Int
|
||||
subscriberUrl String
|
||||
createdAt DateTime @default(now())
|
||||
active Boolean @default(true)
|
||||
eventTriggers WebhookTriggerEvents[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
id String @id @unique
|
||||
userId Int
|
||||
subscriberUrl String
|
||||
payloadTemplate String?
|
||||
createdAt DateTime @default(now())
|
||||
active Boolean @default(true)
|
||||
eventTriggers WebhookTriggerEvents[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
|
|
@ -79,6 +79,7 @@
|
|||
"webhook_created_successfully": "Webhook created successfully!",
|
||||
"webhook_updated_successfully": "Webhook updated successfully!",
|
||||
"webhook_removed_successfully": "Webhook removed successfully!",
|
||||
"payload_template": "Payload Template",
|
||||
"dismiss": "Dismiss",
|
||||
"no_data_yet": "No data yet",
|
||||
"ping_test": "Ping test",
|
||||
|
@ -533,4 +534,4 @@
|
|||
"not_installed": "Not installed",
|
||||
"error_password_mismatch": "Passwords don't match.",
|
||||
"error_required_field": "This field is required."
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import { z } from "zod";
|
|||
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
|
||||
import { createProtectedRouter } from "@server/createRouter";
|
||||
|
||||
|
@ -38,6 +39,7 @@ export const webhookRouter = createProtectedRouter()
|
|||
subscriberUrl: z.string().url().optional(),
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
active: z.boolean().optional(),
|
||||
payloadTemplate: z.string().nullable(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { id, ...data } = input;
|
||||
|
@ -88,50 +90,39 @@ export const webhookRouter = createProtectedRouter()
|
|||
input: z.object({
|
||||
url: z.string().url(),
|
||||
type: z.string(),
|
||||
payloadTemplate: z.string().optional().nullable(),
|
||||
}),
|
||||
async resolve({ input }) {
|
||||
const { url, type } = input;
|
||||
const { url, type, payloadTemplate } = input;
|
||||
|
||||
const responseBodyMocks: Record<"PING", unknown> = {
|
||||
PING: {
|
||||
triggerEvent: "PING",
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: {
|
||||
type: "Test",
|
||||
title: "Test trigger event",
|
||||
description: "",
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
organizer: {
|
||||
name: "Cal",
|
||||
email: "",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
const data = {
|
||||
type: "Test",
|
||||
title: "Test trigger event",
|
||||
description: "",
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
attendees: [
|
||||
{
|
||||
email: "jdoe@example.com",
|
||||
name: "John Doe",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
],
|
||||
organizer: {
|
||||
name: "Cal",
|
||||
email: "",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
};
|
||||
|
||||
const body = responseBodyMocks[type as "PING"];
|
||||
if (!body) {
|
||||
throw new Error(`Unknown type '${type}'`);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
// [...]
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
message: text,
|
||||
};
|
||||
return await sendPayload(type, new Date().toISOString(), url, data, payloadTemplate);
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
const error = getErrorFromUnknown(_err);
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
message: err.message,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue