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 <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
authored and GitHub committed 2024-10-22 03:38:33 +00:00
1 parent 6df994389b
commit 2f4dd328c3
13 files changed
+274 -11

No files matched your search

@@ -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<any>("@calcom/ui");
return {
@@ -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",
@@ -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! },
];
})
@@ -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,
+50 -5
View File
@@ -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<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
@@ -399,7 +414,12 @@ export async function onFormSubmission(
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; 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]);
+3
View File
@@ -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<void>;
+4
View File
@@ -12,6 +12,10 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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"))),
};
@@ -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<Webhook, "subscriberUrl" | "appId" | "payloadTemplate" | "secret">;
}
) {
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<Webhook, "subscriberUrl" | "appId" | "payloadTemplate" | "secret">;
};
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<Webhook, "subscriberUrl" | "appId" | "payloadTemplate" | "secret">;
};
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);
});
});
@@ -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<void> {
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);
});
}
@@ -58,7 +58,10 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
label: "after_guests_cal_video_no_show",
},
],
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
"routing-forms": [
{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" },
{ value: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, label: "form_submitted_no_event" },
],
} as const;
export type WebhookFormValues = {
+4 -1
View File
@@ -21,7 +21,10 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
"routing-forms": [
WebhookTriggerEvents.FORM_SUBMITTED,
WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
] as const,
};
export const WEBHOOK_TRIGGER_EVENTS = [
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'FORM_SUBMITTED_NO_EVENT';
+1
View File
@@ -778,6 +778,7 @@ enum WebhookTriggerEvents {
OOO_CREATED
AFTER_HOSTS_CAL_VIDEO_NO_SHOW
AFTER_GUESTS_CAL_VIDEO_NO_SHOW
FORM_SUBMITTED_NO_EVENT
}
model Webhook {