rewrite webhooks to trpc (#1065)
This commit is contained in:
parent
41382caa6c
commit
dddb494071
5 changed files with 147 additions and 174 deletions
8
lib/webhooks/constants.ts
Normal file
8
lib/webhooks/constants.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { WebhookTriggerEvents } from "@prisma/client";
|
||||||
|
|
||||||
|
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
|
||||||
|
export const WEBHOOK_TRIGGER_EVENTS = [
|
||||||
|
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||||
|
WebhookTriggerEvents.BOOKING_CREATED,
|
||||||
|
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||||
|
] as const;
|
|
@ -1,50 +0,0 @@
|
||||||
import dayjs from "dayjs";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import short from "short-uuid";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
|
|
||||||
dayjs.extend(utc);
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const session = await getSession({ req });
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List webhooks
|
|
||||||
if (req.method === "GET") {
|
|
||||||
const webhooks = await prisma.webhook.findMany({
|
|
||||||
where: {
|
|
||||||
userId: session.user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ webhooks: webhooks });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "POST") {
|
|
||||||
const translator = short();
|
|
||||||
const seed = `${req.body.subscriberUrl}:${dayjs(new Date()).utc().format()}`;
|
|
||||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
|
||||||
|
|
||||||
await prisma.webhook.create({
|
|
||||||
data: {
|
|
||||||
id: uid,
|
|
||||||
userId: session.user.id,
|
|
||||||
subscriberUrl: req.body.subscriberUrl,
|
|
||||||
eventTriggers: req.body.eventTriggers,
|
|
||||||
active: req.body.enabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json({ message: "Webhook created" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(404).json({ message: "Webhook not found" });
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const session = await getSession({ req: req });
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/webhook/{hook}
|
|
||||||
const webhook = await prisma.webhook.findFirst({
|
|
||||||
where: {
|
|
||||||
id: String(req.query.hook),
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!webhook) {
|
|
||||||
return res.status(404).json({ message: "Invalid Webhook" });
|
|
||||||
}
|
|
||||||
if (req.method === "GET") {
|
|
||||||
return res.status(200).json({ webhook });
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/webhook/{hook}
|
|
||||||
if (req.method === "DELETE") {
|
|
||||||
await prisma.webhook.delete({
|
|
||||||
where: {
|
|
||||||
id: String(req.query.hook),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.status(200).json({});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "PATCH") {
|
|
||||||
await prisma.webhook.update({
|
|
||||||
where: {
|
|
||||||
id: webhook.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
subscriberUrl: req.body.subscriberUrl,
|
|
||||||
eventTriggers: req.body.eventTriggers,
|
|
||||||
active: req.body.enabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ message: "Webhook updated successfully" });
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +1,23 @@
|
||||||
import {
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
PencilAltIcon,
|
PencilAltIcon,
|
||||||
SwitchHorizontalIcon,
|
SwitchHorizontalIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronUpIcon,
|
|
||||||
} from "@heroicons/react/outline";
|
} from "@heroicons/react/outline";
|
||||||
import { ClipboardIcon } from "@heroicons/react/solid";
|
import { ClipboardIcon } from "@heroicons/react/solid";
|
||||||
import { WebhookTriggerEvents } from "@prisma/client";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import * as fetcher from "@lib/core/http/fetch-wrapper";
|
|
||||||
import { getErrorFromUnknown } from "@lib/errors";
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
|
||||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||||
|
@ -40,16 +38,10 @@ import Switch from "@components/ui/Switch";
|
||||||
type TIntegrations = inferQueryOutput<"viewer.integrations">;
|
type TIntegrations = inferQueryOutput<"viewer.integrations">;
|
||||||
type TWebhook = TIntegrations["webhooks"][number];
|
type TWebhook = TIntegrations["webhooks"][number];
|
||||||
|
|
||||||
const ALL_TRIGGERS: WebhookTriggerEvents[] = [
|
|
||||||
//
|
|
||||||
"BOOKING_CREATED",
|
|
||||||
"BOOKING_RESCHEDULED",
|
|
||||||
"BOOKING_CANCELLED",
|
|
||||||
];
|
|
||||||
function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const deleteWebhook = useMutation(async () => fetcher.remove(`/api/webhooks/${props.webhook.id}`, null), {
|
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
await utils.invalidateQueries(["viewer.integrations"]);
|
||||||
},
|
},
|
||||||
|
@ -110,7 +102,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }
|
||||||
title={t("delete_webhook")}
|
title={t("delete_webhook")}
|
||||||
confirmBtnText={t("confirm_delete_webhook")}
|
confirmBtnText={t("confirm_delete_webhook")}
|
||||||
cancelBtnText={t("cancel")}
|
cancelBtnText={t("cancel")}
|
||||||
onConfirm={() => deleteWebhook.mutate()}>
|
onConfirm={() => deleteWebhook.mutate({ id: props.webhook.id })}>
|
||||||
{t("delete_webhook_confirmation_message")}
|
{t("delete_webhook_confirmation_message")}
|
||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -185,7 +177,7 @@ function WebhookDialogForm(props: {
|
||||||
const {
|
const {
|
||||||
defaultValues = {
|
defaultValues = {
|
||||||
id: "",
|
id: "",
|
||||||
eventTriggers: ALL_TRIGGERS,
|
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
|
||||||
subscriberUrl: "",
|
subscriberUrl: "",
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
@ -194,7 +186,6 @@ function WebhookDialogForm(props: {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
data-testid="WebhookDialogForm"
|
data-testid="WebhookDialogForm"
|
||||||
|
@ -202,18 +193,12 @@ function WebhookDialogForm(props: {
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
form
|
form
|
||||||
.handleSubmit(async (values) => {
|
.handleSubmit(async (values) => {
|
||||||
const { id } = values;
|
if (values.id) {
|
||||||
const body = {
|
await utils.client.mutation("viewer.webhook.edit", values);
|
||||||
subscriberUrl: values.subscriberUrl,
|
|
||||||
enabled: values.active,
|
|
||||||
eventTriggers: values.eventTriggers,
|
|
||||||
};
|
|
||||||
if (id) {
|
|
||||||
await fetcher.patch(`/api/webhooks/${id}`, body);
|
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
await utils.invalidateQueries(["viewer.integrations"]);
|
||||||
showToast(t("webhook_updated_successfully"), "success");
|
showToast(t("webhook_updated_successfully"), "success");
|
||||||
} else {
|
} else {
|
||||||
await fetcher.post("/api/webhook", body);
|
await utils.client.mutation("viewer.webhook.create", values);
|
||||||
await utils.invalidateQueries(["viewer.integrations"]);
|
await utils.invalidateQueries(["viewer.integrations"]);
|
||||||
showToast(t("webhook_created_successfully"), "success");
|
showToast(t("webhook_created_successfully"), "success");
|
||||||
}
|
}
|
||||||
|
@ -248,7 +233,7 @@ function WebhookDialogForm(props: {
|
||||||
<fieldset className="space-y-2">
|
<fieldset className="space-y-2">
|
||||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||||
<InputGroupBox className="border-0 bg-gray-50">
|
<InputGroupBox className="border-0 bg-gray-50">
|
||||||
{ALL_TRIGGERS.map((key) => (
|
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
|
||||||
<Controller
|
<Controller
|
||||||
key={key}
|
key={key}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -1,58 +1,139 @@
|
||||||
|
import { v4 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getErrorFromUnknown } from "@lib/errors";
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
|
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||||
|
|
||||||
import { createProtectedRouter } from "@server/createRouter";
|
import { createProtectedRouter } from "@server/createRouter";
|
||||||
|
|
||||||
export const webhookRouter = createProtectedRouter().mutation("testTrigger", {
|
export const webhookRouter = createProtectedRouter()
|
||||||
input: z.object({
|
.query("list", {
|
||||||
url: z.string().url(),
|
async resolve({ ctx }) {
|
||||||
type: z.string(),
|
return await ctx.prisma.webhook.findMany({
|
||||||
}),
|
where: {
|
||||||
async resolve({ input }) {
|
userId: ctx.user.id,
|
||||||
const { url, type } = input;
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mutation("create", {
|
||||||
|
input: z.object({
|
||||||
|
subscriberUrl: z.string().url(),
|
||||||
|
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
|
||||||
|
active: z.boolean(),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
return await ctx.prisma.webhook.create({
|
||||||
|
data: {
|
||||||
|
id: v4(),
|
||||||
|
userId: ctx.user.id,
|
||||||
|
...input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mutation("edit", {
|
||||||
|
input: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
subscriberUrl: z.string().url().optional(),
|
||||||
|
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
const webhook = await ctx.prisma.webhook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!webhook) {
|
||||||
|
// user does not own this webhook
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await ctx.prisma.webhook.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mutation("delete", {
|
||||||
|
input: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
const { id } = input;
|
||||||
|
const webhook = await ctx.prisma.webhook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!webhook) {
|
||||||
|
// user does not own this webhook
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await ctx.prisma.webhook.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mutation("testTrigger", {
|
||||||
|
input: z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
type: z.string(),
|
||||||
|
}),
|
||||||
|
async resolve({ input }) {
|
||||||
|
const { url, type } = input;
|
||||||
|
|
||||||
const responseBodyMocks: Record<"PING", unknown> = {
|
const responseBodyMocks: Record<"PING", unknown> = {
|
||||||
PING: {
|
PING: {
|
||||||
triggerEvent: "PING",
|
triggerEvent: "PING",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
payload: {
|
payload: {
|
||||||
type: "Test",
|
type: "Test",
|
||||||
title: "Test trigger event",
|
title: "Test trigger event",
|
||||||
description: "",
|
description: "",
|
||||||
startTime: new Date().toISOString(),
|
startTime: new Date().toISOString(),
|
||||||
endTime: new Date().toISOString(),
|
endTime: new Date().toISOString(),
|
||||||
organizer: {
|
organizer: {
|
||||||
name: "Cal",
|
name: "Cal",
|
||||||
email: "",
|
email: "",
|
||||||
timeZone: "Europe/London",
|
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,
|
|
||||||
};
|
};
|
||||||
} catch (_err) {
|
|
||||||
const err = getErrorFromUnknown(_err);
|
const body = responseBodyMocks[type as "PING"];
|
||||||
return {
|
if (!body) {
|
||||||
status: 500,
|
throw new Error(`Unknown type '${type}'`);
|
||||||
message: err.message,
|
}
|
||||||
};
|
|
||||||
}
|
try {
|
||||||
},
|
const res = await fetch(url, {
|
||||||
});
|
method: "POST",
|
||||||
|
// [...]
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
message: text,
|
||||||
|
};
|
||||||
|
} catch (_err) {
|
||||||
|
const err = getErrorFromUnknown(_err);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
message: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue