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:
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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"))),
|
||||
};
|
||||
|
||||
|
||||
+126
@@ -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);
|
||||
});
|
||||
});
|
||||
+70
@@ -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 = {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'FORM_SUBMITTED_NO_EVENT';
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user