From 2f4dd328c31096c81f2717576a039e987e06b646 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Mon, 21 Oct 2024 23:38:33 -0400 Subject: [PATCH] feat: new webhook 'Form submitted, no event booked' (#17119) * add new webhook * schedule trigger functions * implement tasker * fix type errors * add tests * remove unused code * finish tests * remove unsued code from debugging * Fix: client-server mixed exports * Update tasker.ts --------- Co-authored-by: CarinaWolli Co-authored-by: zomars --- .../dialog/__tests__/RerouteDialog.test.tsx | 1 + apps/web/public/static/locales/en/common.json | 1 + .../trpc/__tests__/utils.test.ts | 1 + .../routing-forms/trpc/response.handler.ts | 11 +- .../app-store/routing-forms/trpc/utils.ts | 55 +++++++- packages/features/tasker/tasker.ts | 3 + packages/features/tasker/tasks/index.ts | 4 + ...triggerFormSubmittedNoEventWebhook.test.ts | 126 ++++++++++++++++++ .../triggerFormSubmittedNoEventWebhook.ts | 70 ++++++++++ .../webhooks/components/WebhookForm.tsx | 5 +- packages/features/webhooks/lib/constants.ts | 5 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + 13 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.test.ts create mode 100644 packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts create mode 100644 packages/prisma/migrations/20241014202509_add_form_submitted_no_event_webhook/migration.sql diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx index 13dc8af70d..13e60cdb5d 100644 --- a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -83,6 +83,7 @@ vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({ })); vi.mock("@calcom/ui", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const actual = await importOriginal("@calcom/ui"); return { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b5b587e55f..929b3334d3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -488,6 +488,7 @@ "booking_payment_initiated": "Booking Payment Initiated", "meeting_ended": "Meeting Ended", "form_submitted": "Form Submitted", + "form_submitted_no_event": "Form submitted, no event booked", "booking_paid": "Booking Paid", "ooo_created": "Out of Office Created", "booking_no_show_updated": "Booking No-Show Updated", diff --git a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts index 1a7c1aa130..f3ac82a2ff 100644 --- a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts +++ b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts @@ -35,6 +35,7 @@ function mockAttributesScenario({ Object.entries(member.attributes).map(([attributeId, value]) => { return [ attributeId, + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain { value, type: attributes.find((attribute) => attribute.id === attributeId)?.type! }, ]; }) diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 701e60771e..2a7c1d8d6a 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -141,10 +141,6 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => const teamMemberIdsMatchingAttributeLogic = teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map((member) => member.userId) : null; - await onFormSubmission( - { ...serializableFormWithFields, userWithEmails }, - dbFormResponse.response as FormResponse - ); const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); if (!chosenRoute) { @@ -153,6 +149,13 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => message: "Chosen route not found", }); } + + await onFormSubmission( + { ...serializableFormWithFields, userWithEmails }, + dbFormResponse.response as FormResponse, + dbFormResponse.id, + "action" in chosenRoute ? chosenRoute.action : undefined + ); return { formResponse: dbFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 21a48c1be5..4cb162aa6d 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -4,6 +4,8 @@ import type { ImmutableTree, JsonTree } from "react-awesome-query-builder"; import type { Config } from "react-awesome-query-builder/lib"; import { Utils as QbUtils } from "react-awesome-query-builder/lib"; +import dayjs from "@calcom/dayjs"; +import type { Tasker } from "@calcom/features/tasker/tasker"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; @@ -22,6 +24,18 @@ import type { SerializableField, OrderedResponses } from "../types/types"; import type { FormResponse, SerializableForm } from "../types/types"; import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils"; +let tasker: Tasker; + +if (typeof window === "undefined") { + import("@calcom/features/tasker") + .then((module) => { + tasker = module.default; + }) + .catch((error) => { + console.error("Failed to load tasker:", error); + }); +} + const { getAttributesData: getAttributes, getAttributesQueryBuilderConfig, @@ -31,7 +45,7 @@ const { const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] }); type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null }; -type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record< +export type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record< string, { /** @@ -44,6 +58,7 @@ type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record< value: FormResponse[keyof FormResponse]["value"]; } >; + type TeamMemberWithAttributeOptionValuePerAttribute = Awaited< ReturnType >[number]; @@ -399,7 +414,12 @@ export async function onFormSubmission( SerializableForm & { user: Pick; userWithEmails?: string[] }, "fields" >, - response: FormResponse + response: FormResponse, + responseId: number, + chosenAction?: { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + } ) { const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {}; @@ -422,16 +442,24 @@ export async function onFormSubmission( const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId }); - const subscriberOptions = { + const subscriberOptionsFormSubmitted = { userId, teamId, orgId, triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, }; - const webhooks = await getWebhooks(subscriberOptions); + const subscriberOptionsFormSubmittedNoEvent = { + userId, + teamId, + orgId, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, + }; - const promises = webhooks.map((webhook) => { + const webhooksFormSubmitted = await getWebhooks(subscriberOptionsFormSubmitted); + const webhooksFormSubmittedNoEvent = await getWebhooks(subscriberOptionsFormSubmittedNoEvent); + + const promisesFormSubmitted = webhooksFormSubmitted.map((webhook) => { sendGenericWebhookPayload({ secretKey: webhook.secret, triggerEvent: "FORM_SUBMITTED", @@ -455,6 +483,23 @@ export async function onFormSubmission( }); }); + const promisesFormSubmittedNoEvent = webhooksFormSubmittedNoEvent.map((webhook) => { + const scheduledAt = dayjs().add(10, "minute").toDate(); + return tasker.create( + "triggerFormSubmittedNoEventWebhook", + { + responseId, + form, + responses: fieldResponsesByIdentifier, + redirect: chosenAction, + webhook, + }, + { scheduledAt } + ); + }); + + const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent]; + await Promise.all(promises); const orderedResponses = form.fields.reduce((acc, field) => { acc.push(response[field.id]); diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index feec7869f0..aa6e39f945 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -11,6 +11,9 @@ type TaskPayloads = { triggerGuestNoShowWebhook: z.infer< typeof import("./tasks/triggerNoShow/schema").ZSendNoShowWebhookPayloadSchema >; + triggerFormSubmittedNoEventWebhook: z.infer< + typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema + >; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string) => Promise; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index c2bd33ff55..2c1c152847 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -12,6 +12,10 @@ const tasks: Record Promise> = { import("./triggerNoShow/triggerHostNoShow").then((module) => module.triggerHostNoShow), triggerGuestNoShowWebhook: () => import("./triggerNoShow/triggerGuestNoShow").then((module) => module.triggerGuestNoShow), + triggerFormSubmittedNoEventWebhook: () => + import("./triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").then( + (module) => module.triggerFormSubmittedNoEventWebhook + ), sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))), }; diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.test.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.test.ts new file mode 100644 index 0000000000..30285e8889 --- /dev/null +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.test.ts @@ -0,0 +1,126 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import type { ResponseData } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook"; +import { triggerFormSubmittedNoEventWebhook } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook"; +import type { Webhook } from "@calcom/prisma/client"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +function expectFormSubmittedNoEventWebhookToBeCalled( + payload: ResponseData & { + webhook: Pick; + } +) { + const fetchCalls = fetchMock.mock.calls; + + const webhooksToSubscriberUrl = fetchCalls.filter((call) => { + return call[0] === payload.webhook.subscriberUrl; + }); + + const webhookFetchCall = webhooksToSubscriberUrl.find((call) => { + const body = call[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + return parsedBody.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT; + }); + + if (!webhookFetchCall) { + throw new Error( + `Webhook not sent to ${ + payload.webhook.subscriberUrl + } for FORM_SUBMITTED_NO_EVENT. All webhooks: ${JSON.stringify(webhooksToSubscriberUrl)}` + ); + } + + expect(webhookFetchCall[0]).toBe(payload.webhook.subscriberUrl); + + const body = webhookFetchCall[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + expect(parsedBody.triggerEvent).toBe(WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT); + + const webhookPayload = { + responses: payload.responses, + formId: payload.form.id, + formName: payload.form.name, + teamId: null, + }; + + expect(parsedBody.payload).toEqual(webhookPayload); +} + +describe("Form submitted, no event booked trigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it(`should trigger when form was submitted but no booking was made`, async () => { + const payload = { + responseId: 1, + responses: { + "Test field 1": { + value: "Test input 1", + response: "Test input 1", + }, + }, + form: { + id: "1234", + name: "Test Form 1", + teamId: null, + }, + webhook: { + subscriberUrl: "https://example1.com", + appId: null, + payloadTemplate: null, + secret: null, + }, + } satisfies ResponseData & { + webhook: Pick; + }; + const payloadString = JSON.stringify(payload); + + prismaMock.booking.findFirst.mockResolvedValue(null); + + const booking = await prismaMock.booking.findFirst(); + + await triggerFormSubmittedNoEventWebhook(payloadString); + + expectFormSubmittedNoEventWebhookToBeCalled(payload); + }); + + it(`should not trigger when form was submitted and also booking was made after`, async () => { + const payload = { + responseId: 1, + responses: { + "Test field 2": { + value: "Test input 2", + response: "Test input 2", + }, + }, + form: { + id: "6789", + name: "Test Form 2", + teamId: null, + }, + webhook: { + subscriberUrl: "https://example2.com", + appId: null, + payloadTemplate: null, + secret: null, + }, + } satisfies ResponseData & { + webhook: Pick; + }; + const payloadString = JSON.stringify(payload); + + prismaMock.booking.findFirst.mockResolvedValue({ id: 5 }); + + const booking = await prismaMock.booking.findFirst(); + + await triggerFormSubmittedNoEventWebhook(payloadString); + + const webhooksToSubscriberUrl = fetchMock.mock.calls.filter((call) => { + return call[0] === payload.webhook.subscriberUrl; + }); + + expect(webhooksToSubscriberUrl.length).toBe(0); + }); +}); diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts new file mode 100644 index 0000000000..225bbe8115 --- /dev/null +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import prisma from "@calcom/prisma"; + +export type ResponseData = { + responseId: number; + responses: FORM_SUBMITTED_WEBHOOK_RESPONSES; + form: { id: string; name: string; teamId: number | null }; + redirect?: { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + }; +}; + +export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({ + webhook: z.object({ + subscriberUrl: z.string().url(), + appId: z.string().nullable(), + payloadTemplate: z.string().nullable(), + secret: z.string().nullable(), + }), + responseId: z.number(), + responses: z.any(), + redirect: z + .object({ + type: z.enum(["customPageMessage", "externalRedirectUrl", "eventTypeRedirectUrl"]), + value: z.string(), + }) + .optional(), + form: z.object({ + id: z.string(), + name: z.string(), + teamId: z.number().nullable(), + }), +}); + +export async function triggerFormSubmittedNoEventWebhook(payload: string): Promise { + const { webhook, responseId, form, redirect, responses } = + ZTriggerFormSubmittedNoEventWebhookPayloadSchema.parse(JSON.parse(payload)); + + const bookingFromResponse = await prisma.booking.findFirst({ + where: { + routedFromRoutingFormReponse: { + id: responseId, + }, + }, + }); + + if (bookingFromResponse) { + return; + } + + await sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent: "FORM_SUBMITTED_NO_EVENT", + createdAt: new Date().toISOString(), + webhook, + data: { + formId: form.id, + formName: form.name, + teamId: form.teamId, + redirect, + responses, + }, + }).catch((e) => { + console.error(`Error executing FORM_SUBMITTED_NO_EVENT webhook`, webhook, e); + }); +} diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 92433a213b..9301e911c7 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -58,7 +58,10 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record