From 5b7cd476a75db2c92758c5663a18b744d0d85048 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 21 Jul 2022 00:00:57 +0530 Subject: [PATCH] Feature: Routing Forms Webhook for Form Responses (#3444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Omar López Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../components/webhook/WebhookDialogForm.tsx | 17 ++++++--- .../webhook/WebhookListContainer.tsx | 12 ++++-- apps/web/lib/webhooks/constants.ts | 17 +++++++-- apps/web/lib/webhooks/sendPayload.tsx | 32 ++++++++++++++-- apps/web/pages/settings/developer.tsx | 17 ++++++++- apps/web/public/static/locales/en/common.json | 1 + apps/web/server/routers/viewer/webhook.tsx | 6 ++- .../routing_forms/api/responses/[formId].ts | 2 +- .../components/RoutingNavBar.tsx | 2 +- .../pages/form-edit/[...appPages].tsx | 35 +++++++++++++++++ .../playwright/tests/basic.test.ts | 3 ++ .../app-store/ee/routing_forms/trpc-router.ts | 38 ++++++++++++++++++- packages/app-store/ee/routing_forms/zod.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + 15 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 packages/prisma/migrations/20220719110415_form_submitted_webhook/migration.sql diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx index 9fa9487952..4ca59e5c6b 100644 --- a/apps/web/components/webhook/WebhookDialogForm.tsx +++ b/apps/web/components/webhook/WebhookDialogForm.tsx @@ -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: {
{t("event_triggers")} - {WEBHOOK_TRIGGER_EVENTS.map((key) => ( + {triggers.map((key) => ( (null); @@ -66,6 +70,7 @@ export default function WebhookListContainer(props: WebhookListContainerType) { !isOpen && setNewWebhookModal(false)}> setNewWebhookModal(false)} /> @@ -76,6 +81,7 @@ export default function WebhookListContainer(props: WebhookListContainerType) { {editing && ( setEditModalOpen(false)} diff --git a/apps/web/lib/webhooks/constants.ts b/apps/web/lib/webhooks/constants.ts index ddd5a44507..ee02568bce 100644 --- a/apps/web/lib/webhooks/constants.ts +++ b/apps/web/lib/webhooks/constants.ts @@ -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"]; diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx index 4bfa6b440c..5eb1d019cf 100644 --- a/apps/web/lib/webhooks/sendPayload.tsx +++ b/apps/web/lib/webhooks/sendPayload.tsx @@ -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, + data: Record +) => { + 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, + 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"; diff --git a/apps/web/pages/settings/developer.tsx b/apps/web/pages/settings/developer.tsx index b032c4be5f..2c052e6bee 100644 --- a/apps/web/pages/settings/developer.tsx +++ b/apps/web/pages/settings/developer.tsx @@ -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 ( - + + {routingForms && ( + + )} ); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 077fd6163d..6e8e59f94a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx index 1456260bd7..39700287aa 100644 --- a/apps/web/server/routers/viewer/webhook.tsx +++ b/apps/web/server/routers/viewer/webhook.tsx @@ -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(), diff --git a/packages/app-store/ee/routing_forms/api/responses/[formId].ts b/packages/app-store/ee/routing_forms/api/responses/[formId].ts index 9abd79c39b..af73e86941 100644 --- a/packages/app-store/ee/routing_forms/api/responses/[formId].ts +++ b/packages/app-store/ee/routing_forms/api/responses/[formId].ts @@ -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) { diff --git a/packages/app-store/ee/routing_forms/components/RoutingNavBar.tsx b/packages/app-store/ee/routing_forms/components/RoutingNavBar.tsx index 0cd701cc83..7eb792ecd4 100644 --- a/packages/app-store/ee/routing_forms/components/RoutingNavBar.tsx +++ b/packages/app-store/ee/routing_forms/components/RoutingNavBar.tsx @@ -11,7 +11,7 @@ export default function RoutingNavBar({ }) { const tabs = [ { - name: "Fields", + name: "Form", href: `${appUrl}/form-edit/${form?.id}`, }, { diff --git a/packages/app-store/ee/routing_forms/pages/form-edit/[...appPages].tsx b/packages/app-store/ee/routing_forms/pages/form-edit/[...appPages].tsx index 5f4e1ac110..57de747685 100644 --- a/packages/app-store/ee/routing_forms/pages/form-edit/[...appPages].tsx +++ b/packages/app-store/ee/routing_forms/pages/form-edit/[...appPages].tsx @@ -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 (
+
+
+ +
+
+ setUserChangedIdentifier(e.target.value)} + className="block w-full rounded-sm border-gray-300 text-sm" + /> +
+