rewrite webhooks to trpc (#1065)

pull/1050/head^2
Alex Johansson 2021-10-28 23:52:39 +01:00 committed by GitHub
parent 41382caa6c
commit dddb494071
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 147 additions and 174 deletions

View 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;

View File

@ -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" });
}

View File

@ -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" });
}
}

View File

@ -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}

View File

@ -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,
};
}
},
});