Feature: Routing Forms Webhook for Form Responses (#3444)

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2022-07-21 00:00:57 +05:30 committed by GitHub
parent b47c75304e
commit 5b7cd476a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 164 additions and 22 deletions

View File

@ -3,14 +3,13 @@ import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Tooltip } from "@calcom/ui";
import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
import { trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants";
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
import { TWebhook } from "@components/webhook/WebhookListItem";
@ -19,14 +18,20 @@ import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
export default function WebhookDialogForm(props: {
eventTypeId?: number;
defaultValues?: TWebhook;
app?: string;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const appId = props.app;
const triggers = !appId
? WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["core"]
: WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP[appId as keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP];
const {
defaultValues = {
id: "",
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
eventTriggers: triggers,
subscriberUrl: "",
active: true,
payloadTemplate: null,
@ -60,8 +65,8 @@ export default function WebhookDialogForm(props: {
form={form}
handleSubmit={async (event) => {
const e = changeSecret
? { ...event, eventTypeId: props.eventTypeId }
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId };
? { ...event, eventTypeId: props.eventTypeId, appId }
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId, appId };
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
@ -115,7 +120,7 @@ export default function WebhookDialogForm(props: {
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
{triggers.map((key) => (
<Controller
key={key}
control={form.control}

View File

@ -17,12 +17,16 @@ export type WebhookListContainerType = {
title: string;
subtitle: string;
eventTypeId?: number;
appId?: string;
};
export default function WebhookListContainer(props: WebhookListContainerType) {
const query = trpc.useQuery(["viewer.webhook.list", { eventTypeId: props.eventTypeId }], {
suspense: true,
});
const query = trpc.useQuery(
["viewer.webhook.list", { eventTypeId: props.eventTypeId, appId: props.appId }],
{
suspense: true,
}
);
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
@ -66,6 +70,7 @@ export default function WebhookListContainer(props: WebhookListContainerType) {
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm
app={props.appId}
eventTypeId={props.eventTypeId}
handleClose={() => setNewWebhookModal(false)}
/>
@ -76,6 +81,7 @@ export default function WebhookListContainer(props: WebhookListContainerType) {
<DialogContent>
{editing && (
<WebhookDialogForm
app={props.appId}
key={editing.id}
eventTypeId={props.eventTypeId || undefined}
handleClose={() => setEditModalOpen(false)}

View File

@ -1,8 +1,17 @@
import { WebhookTriggerEvents } from "@prisma/client";
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
core: [
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"],
routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
};
export const WEBHOOK_TRIGGER_EVENTS = [
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"];
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms,
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED", "FORM_SUBMITTED"];

View File

@ -34,10 +34,7 @@ const sendPayload = async (
bookingId?: number;
}
) => {
const { subscriberUrl, appId, payloadTemplate: template } = webhook;
if (!subscriberUrl || !data) {
throw new Error("Missing required elements to send webhook payload.");
}
const { appId, payloadTemplate: template } = webhook;
const contentType =
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
@ -59,6 +56,33 @@ const sendPayload = async (
});
}
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, contentType);
};
export const sendGenericWebhookPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
data: Record<string, unknown>
) => {
const body = JSON.stringify(data);
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, "application/json");
};
const _sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
body: string,
contentType: "application/json" | "application/x-www-form-urlencoded"
) => {
const { subscriberUrl } = webhook;
if (!subscriberUrl || !body) {
throw new Error("Missing required elements to send webhook payload.");
}
const secretSignature = secretKey
? createHmac("sha256", secretKey).update(`${body}`).digest("hex")
: "no-secret-provided";

View File

@ -1,15 +1,30 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import ApiKeyListContainer from "@ee/components/apiKeys/ApiKeyListContainer";
import { trpc } from "@lib/trpc";
import SettingsShell from "@components/SettingsShell";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
export default function Settings() {
const { t } = useLocale();
const { data: routingForms } = trpc.useQuery([
"viewer.appById",
{
appId: "routing_forms",
},
]);
return (
<SettingsShell heading={t("developer")} subtitle={t("manage_developer_settings")}>
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
{routingForms && (
<WebhookListContainer
appId="routing_forms"
title="Routing Webhooks"
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
/>
)}
<ApiKeyListContainer />
</SettingsShell>
);

View File

@ -344,6 +344,7 @@
"booking_cancelled": "Booking Cancelled",
"booking_rescheduled": "Booking Rescheduled",
"booking_created": "Booking Created",
"form_submitted": "Form Submitted",
"event_triggers": "Event Triggers",
"subscriber_url": "Subscriber Url",
"create_new_webhook": "Create a new webhook",

View File

@ -71,11 +71,13 @@ export const webhookRouter = createProtectedRouter()
input: z
.object({
eventTypeId: z.number().optional(),
appId: z.string().optional(),
})
.optional(),
async resolve({ ctx, input }) {
const where: Prisma.WebhookWhereInput = {
AND: [{ appId: null /* Don't mixup zapier webhooks with normal ones */ }],
/* Don't mixup zapier webhooks with normal ones */
AND: [{ appId: !input?.appId ? null : input.appId }],
};
if (Array.isArray(where.AND)) {
if (input?.eventTypeId) {
@ -84,6 +86,7 @@ export const webhookRouter = createProtectedRouter()
where.AND?.push({ userId: ctx.user.id });
}
}
return await ctx.prisma.webhook.findMany({
where,
});
@ -108,6 +111,7 @@ export const webhookRouter = createProtectedRouter()
},
});
}
return await ctx.prisma.webhook.create({
data: {
id: v4(),

View File

@ -38,7 +38,6 @@ async function* getResponses(formId: string) {
} else {
serializedValue = escapeCsvText(value);
}
csvLineColumns.push(`"Field Label :=> Value"`);
csvLineColumns.push(`"${label} :=> ${serializedValue}"`);
}
csv.push(csvLineColumns.join(","));
@ -48,6 +47,7 @@ async function* getResponses(formId: string) {
}
return "";
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { args } = req.query;
if (!args) {

View File

@ -11,7 +11,7 @@ export default function RoutingNavBar({
}) {
const tabs = [
{
name: "Fields",
name: "Form",
href: `${appUrl}/form-edit/${form?.id}`,
},
{

View File

@ -1,5 +1,6 @@
import { TrashIcon, PlusIcon, ArrowUpIcon, CollectionIcon, ArrowDownIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { useForm, UseFormReturn, useFieldArray, Controller } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
@ -72,6 +73,22 @@ function Field({
fn: () => void;
};
}) {
const [identifier, _setIdentifier] = useState(hookForm.getValues(`${hookFieldNamespace}.identifier`));
const setUserChangedIdentifier = (val: string) => {
_setIdentifier(val);
// Also, update the form identifier so tha it can be persisted
hookForm.setValue(`${hookFieldNamespace}.identifier`, val);
};
const label = hookForm.watch(`${hookFieldNamespace}.label`);
useEffect(() => {
if (!hookForm.getValues(`${hookFieldNamespace}.identifier`)) {
_setIdentifier(label);
}
}, [label, hookFieldNamespace, hookForm]);
return (
<div
data-testid="field"
@ -104,12 +121,30 @@ function Field({
<div className="w-full">
<input
type="text"
placeholder="This is what your users would see"
required
{...hookForm.register(`${hookFieldNamespace}.label`)}
className="block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
</div>
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">
Nickname
</label>
</div>
<div className="w-full">
<input
type="text"
required
placeholder="Identifies field in webhook payloads"
value={identifier}
onChange={(e) => setUserChangedIdentifier(e.target.value)}
className="block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
</div>
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">

View File

@ -64,6 +64,9 @@ test.describe("Forms", () => {
await addForm(page);
await page.click('[href="/apps/routing_forms/forms"]');
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
await page.click('[href="/apps/routing_forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
});

View File

@ -1,7 +1,10 @@
import { Prisma } from "@prisma/client";
import { Prisma, WebhookTriggerEvents } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { sendGenericWebhookPayload } from "@lib/webhooks/sendPayload";
import getWebhooks from "@lib/webhooks/subscriptions";
import { createProtectedRouter, createRouter } from "@server/createRouter";
import { TRPCError } from "@trpc/server";
@ -28,6 +31,9 @@ const app_RoutingForms = createRouter()
where: {
id: formId,
},
include: {
user: true,
},
});
if (!form) {
throw new TRPCError({
@ -87,6 +93,36 @@ const app_RoutingForms = createRouter()
});
}
const fieldResponsesByName: Record<string, typeof response[keyof typeof response]["value"]> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value;
}
const subscriberOptions = {
userId: form.user.id,
// It isn't an eventType webhook
eventTypeId: -1,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) => {
sendGenericWebhookPayload(
webhook.secret,
"FORM_SUBMITTED",
new Date().toISOString(),
webhook,
fieldResponsesByName
).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
});
});
await Promise.all(promises);
return await prisma.app_RoutingForms_FormResponse.create({
data: input,
});

View File

@ -5,6 +5,7 @@ export const zodFields = z
z.object({
id: z.string(),
label: z.string(),
identifier: z.string().optional(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'FORM_SUBMITTED';

View File

@ -392,6 +392,7 @@ enum WebhookTriggerEvents {
BOOKING_CREATED
BOOKING_RESCHEDULED
BOOKING_CANCELLED
FORM_SUBMITTED
}
model Webhook {