rewrite webhooks to trpc (#1065)
parent
41382caa6c
commit
dddb494071
|
@ -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 {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilAltIcon,
|
||||
SwitchHorizontalIcon,
|
||||
TrashIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ClipboardIcon } from "@heroicons/react/solid";
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import Image from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
import * as fetcher from "@lib/core/http/fetch-wrapper";
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
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 { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
|
||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
|
@ -40,16 +38,10 @@ import Switch from "@components/ui/Switch";
|
|||
type TIntegrations = inferQueryOutput<"viewer.integrations">;
|
||||
type TWebhook = TIntegrations["webhooks"][number];
|
||||
|
||||
const ALL_TRIGGERS: WebhookTriggerEvents[] = [
|
||||
//
|
||||
"BOOKING_CREATED",
|
||||
"BOOKING_RESCHEDULED",
|
||||
"BOOKING_CANCELLED",
|
||||
];
|
||||
function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
||||
const { t } = useLocale();
|
||||
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() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
|
@ -110,7 +102,7 @@ function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }
|
|||
title={t("delete_webhook")}
|
||||
confirmBtnText={t("confirm_delete_webhook")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() => deleteWebhook.mutate()}>
|
||||
onConfirm={() => deleteWebhook.mutate({ id: props.webhook.id })}>
|
||||
{t("delete_webhook_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
|
@ -185,7 +177,7 @@ function WebhookDialogForm(props: {
|
|||
const {
|
||||
defaultValues = {
|
||||
id: "",
|
||||
eventTriggers: ALL_TRIGGERS,
|
||||
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
|
||||
subscriberUrl: "",
|
||||
active: true,
|
||||
},
|
||||
|
@ -194,7 +186,6 @@ function WebhookDialogForm(props: {
|
|||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
data-testid="WebhookDialogForm"
|
||||
|
@ -202,18 +193,12 @@ function WebhookDialogForm(props: {
|
|||
onSubmit={(event) => {
|
||||
form
|
||||
.handleSubmit(async (values) => {
|
||||
const { id } = values;
|
||||
const body = {
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
enabled: values.active,
|
||||
eventTriggers: values.eventTriggers,
|
||||
};
|
||||
if (id) {
|
||||
await fetcher.patch(`/api/webhooks/${id}`, body);
|
||||
if (values.id) {
|
||||
await utils.client.mutation("viewer.webhook.edit", values);
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
} else {
|
||||
await fetcher.post("/api/webhook", body);
|
||||
await utils.client.mutation("viewer.webhook.create", values);
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
}
|
||||
|
@ -248,7 +233,7 @@ function WebhookDialogForm(props: {
|
|||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
{ALL_TRIGGERS.map((key) => (
|
||||
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
|
||||
<Controller
|
||||
key={key}
|
||||
control={form.control}
|
||||
|
|
|
@ -1,58 +1,139 @@
|
|||
import { v4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||
|
||||
import { createProtectedRouter } from "@server/createRouter";
|
||||
|
||||
export const webhookRouter = createProtectedRouter().mutation("testTrigger", {
|
||||
input: z.object({
|
||||
url: z.string().url(),
|
||||
type: z.string(),
|
||||
}),
|
||||
async resolve({ input }) {
|
||||
const { url, type } = input;
|
||||
export const webhookRouter = createProtectedRouter()
|
||||
.query("list", {
|
||||
async resolve({ ctx }) {
|
||||
return await ctx.prisma.webhook.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.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> = {
|
||||
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 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 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);
|
||||
return {
|
||||
status: 500,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
return {
|
||||
status: 500,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue