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:
parent
b47c75304e
commit
5b7cd476a7
|
@ -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}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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"];
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -11,7 +11,7 @@ export default function RoutingNavBar({
|
|||
}) {
|
||||
const tabs = [
|
||||
{
|
||||
name: "Fields",
|
||||
name: "Form",
|
||||
href: `${appUrl}/form-edit/${form?.id}`,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'FORM_SUBMITTED';
|
|
@ -392,6 +392,7 @@ enum WebhookTriggerEvents {
|
|||
BOOKING_CREATED
|
||||
BOOKING_RESCHEDULED
|
||||
BOOKING_CANCELLED
|
||||
FORM_SUBMITTED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
|
Loading…
Reference in New Issue
Block a user