fix: `FORM_SUBMITTED` webhook payload change and support for Team Webhooks with it (#10986)

pull/11141/head
Hariom Balhara 2023-09-05 02:34:57 +05:30 committed by GitHub
parent 8891641953
commit 41ef354c7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 333 additions and 140 deletions

View File

@ -0,0 +1,61 @@
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
type Route = {
id: string;
action: {
type: string;
value: string;
};
isFallback: boolean;
queryValue: {
id: string;
type: string;
};
};
export const createRoutingFormsFixture = () => {
return {
async create({
userId,
teamId,
name,
fields,
routes = [],
}: {
name: string;
userId: number;
teamId: number | null;
routes?: Route[];
fields: {
type: string;
label: string;
identifier?: string;
required: boolean;
}[];
}) {
return await prisma.app_RoutingForms_Form.create({
data: {
name,
userId,
teamId,
routes: [
...routes,
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: fields.map((f) => ({
id: uuidv4(),
...f,
})),
},
});
},
};
};

View File

@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings"; import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments"; import { createPaymentsFixture } from "../fixtures/payments";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers"; import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users"; import { createUsersFixture } from "../fixtures/users";
@ -23,6 +24,7 @@ export interface Fixtures {
servers: ReturnType<typeof createServersFixture>; servers: ReturnType<typeof createServersFixture>;
prisma: typeof prisma; prisma: typeof prisma;
emails?: API; emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
} }
declare global { declare global {
@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
prisma: async ({}, use) => { prisma: async ({}, use) => {
await use(prisma); await use(prisma);
}, },
routingForms: async ({}, use) => {
await use(createRoutingFormsFixture());
},
emails: async ({}, use) => { emails: async ({}, use) => {
if (IS_MAILHOG_ENABLED) { if (IS_MAILHOG_ENABLED) {
const mailhogAPI = mailhog(); const mailhogAPI = mailhog();

View File

@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
@ -9,6 +10,9 @@ import {
gotoRoutingLink, gotoRoutingLink,
} from "./lib/testUtils"; } from "./lib/testUtils";
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
test.afterEach(({ users }) => users.deleteAll()); test.afterEach(({ users }) => users.deleteAll());
test.describe("BOOKING_CREATED", async () => { test.describe("BOOKING_CREATED", async () => {
@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body: any = request.body; const body: any = request.body;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any; const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any; const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic; body.createdAt = dynamic;
body.payload.startTime = dynamic; body.payload.startTime = dynamic;
body.payload.endTime = dynamic; body.payload.endTime = dynamic;
@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", async () => {
}); });
test.describe("FORM_SUBMITTED", async () => { test.describe("FORM_SUBMITTED", async () => {
test("can submit a form and get a submission event", async ({ page, users }) => { test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer(); const webhookReceiver = createHttpServer();
const user = await users.create(); const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin(); await user.apiLogin();
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
const teamName = `${user.username}'s Team`;
// Create a new team
await page.locator('input[name="name"]').fill(teamName);
await page.locator('input[name="slug"]').fill(teamName);
await page.locator('button[type="submit"]').click();
await page.locator("text=Publish team").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.waitForLoadState("networkidle");
await page.waitForLoadState("networkidle");
await page.goto(`/settings/developer/webhooks/new`); await page.goto(`/settings/developer/webhooks/new`);
// Add webhook // Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url); await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret"); await page.fill('[name="secret"]', "secret");
await Promise.all([page.click("[type=submit]"), page.goForward()]); await page.click("[type=submit]");
// Page contains the url // Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.goto("/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
await page.fill("input[name]", "TEST FORM");
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url(); const form = await routingForms.create({
const formId = new URL(url).pathname.split("/").at(-1); name: "Test Form",
userId: user.id,
teamId: null,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
await gotoRoutingLink({ page, formId: formId }); await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]'); page.click('button[type="submit"]');
await waitFor(() => { await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1); expect(webhookReceiver.requestList.length).toBe(1);
}); });
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
expect(body).toEqual({
triggerEvent: "FORM_SUBMITTED",
createdAt: dynamic,
payload: {
formId: form.id,
formName: form.name,
teamId: null,
responses: {
name: {
value: "John Doe",
},
},
},
name: "John Doe",
});
webhookReceiver.close();
});
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
const teamId = await clickFirstTeamWebhookCta(page);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
const form = await routingForms.create({
name: "Test Form",
userId: user.id,
teamId: teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]');
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
expect(body).toEqual({
triggerEvent: "FORM_SUBMITTED",
createdAt: dynamic,
payload: {
formId: form.id,
formName: form.name,
teamId,
responses: {
name: {
value: "John Doe",
},
},
},
name: "John Doe",
});
webhookReceiver.close(); webhookReceiver.close();
}); });
}); });
async function clickFirstTeamWebhookCta(page: Page) {
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
return teamId;
}

View File

@ -5,6 +5,13 @@ import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures";
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils"; import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
import {
addForm,
saveCurrentForm,
verifySelectOptions,
addOneFieldAndDescriptionAndSaveForm,
} from "./testUtils";
function todo(title: string) { function todo(title: string) {
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function // eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
test.skip(title, () => {}); test.skip(title, () => {});
@ -407,22 +414,6 @@ async function fillSeededForm(page: Page, routingFormId: string) {
await expect(page.locator("text=Custom Page Result")).toBeVisible(); await expect(page.locator("text=Custom Page Result")).toBeVisible();
} }
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
await page.goto("/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
await page.fill("input[name]", name);
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url();
const formId = new URL(url).pathname.split("/").at(-1);
if (!formId) {
throw new Error("Form ID couldn't be determined from url");
}
return formId;
}
async function addAllTypesOfFieldsAndSaveForm( async function addAllTypesOfFieldsAndSaveForm(
formId: string, formId: string,
page: Page, page: Page,
@ -480,46 +471,6 @@ async function addAllTypesOfFieldsAndSaveForm(
}; };
} }
export async function addOneFieldAndDescriptionAndSaveForm(
formId: string,
page: Page,
form: { description?: string; field?: { typeIndex: number; label: string } }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
if (form.description) {
await page.fill('[data-testid="description"]', form.description);
}
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
if (form.field) {
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator(".data-testid-field-type")
.click();
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator('[id*="react-select-"][aria-disabled]')
.nth(form.field.typeIndex)
.click();
}
await saveCurrentForm(page);
return {
types,
};
}
async function selectOption({ async function selectOption({
page, page,
selector, selector,
@ -551,26 +502,6 @@ async function verifyFieldOptionsInRule(options: string[], page: Page) {
); );
} }
async function verifySelectOptions(
selector: { selector: string; nth: number },
expectedOptions: string[],
page: Page
) {
await page.locator(selector.selector).nth(selector.nth).click();
const selectOptions = await page
.locator(selector.selector)
.nth(selector.nth)
.locator('[id*="react-select-"][aria-disabled]')
.allInnerTexts();
const sortedSelectOptions = [...selectOptions].sort();
const sortedExpectedOptions = [...expectedOptions].sort();
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
return {
optionsInUi: selectOptions,
};
}
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) { async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
await selectOption({ await selectOption({
selector: { selector: {
@ -581,8 +512,3 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
page, page,
}); });
} }
async function saveCurrentForm(page: Page) {
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");
}

View File

@ -0,0 +1,83 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
await page.goto("/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
await page.fill("input[name]", name);
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url();
const formId = new URL(url).pathname.split("/").at(-1);
if (!formId) {
throw new Error("Form ID couldn't be determined from url");
}
return formId;
}
export async function addOneFieldAndDescriptionAndSaveForm(
formId: string,
page: Page,
form: { description?: string; field?: { typeIndex: number; label: string } }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
if (form.description) {
await page.fill('[data-testid="description"]', form.description);
}
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
if (form.field) {
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator(".data-testid-field-type")
.click();
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator('[id*="react-select-"][aria-disabled]')
.nth(form.field.typeIndex)
.click();
}
await saveCurrentForm(page);
return {
types,
};
}
export async function saveCurrentForm(page: Page) {
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");
}
export async function verifySelectOptions(
selector: { selector: string; nth: number },
expectedOptions: string[],
page: Page
) {
await page.locator(selector.selector).nth(selector.nth).click();
const selectOptions = await page
.locator(selector.selector)
.nth(selector.nth)
.locator('[id*="react-select-"][aria-disabled]')
.allInnerTexts();
const sortedSelectOptions = [...selectOptions].sort();
const sortedExpectedOptions = [...expectedOptions].sort();
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
return {
optionsInUi: selectOptions,
};
}

View File

@ -12,32 +12,50 @@ export async function onFormSubmission(
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">, form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response response: Response
) { ) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {}; const fieldResponsesByName: Record<
string,
{
value: Response[keyof Response]["value"];
}
> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) { for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field. // Use the label lowercased as the key to identify a field.
const key = const key =
form.fields.find((f) => f.id === fieldId)?.identifier || form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName); (fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value; fieldResponsesByName[key] = {
value: fieldResponse.value,
};
} }
const subscriberOptions = { const subscriberOptions = {
userId: form.user.id,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
// When team routing forms are implemented, we need to make sure to add the teamId here ...getWebhookTargetEntity(form),
teamId: null,
}; };
const webhooks = await getWebhooks(subscriberOptions); const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) => { const promises = webhooks.map((webhook) => {
sendGenericWebhookPayload( sendGenericWebhookPayload({
webhook.secret, secretKey: webhook.secret,
"FORM_SUBMITTED", triggerEvent: "FORM_SUBMITTED",
new Date().toISOString(), createdAt: new Date().toISOString(),
webhook, webhook,
fieldResponsesByName data: {
).catch((e) => { formId: form.id,
formName: form.name,
teamId: form.teamId,
responses: fieldResponsesByName,
},
rootData: {
// Send responses unwrapped at root level for backwards compatibility
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
acc[key] = value.value;
return acc;
}, {} as Record<string, Response[keyof Response]["value"]>),
},
}).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e); console.error(`Error executing routing form webhook`, webhook, e);
}); });
}); });
@ -66,3 +84,10 @@ export const sendResponseEmail = async (
logger.error("Error sending response email", e); logger.error("Error sending response email", e);
} }
}; };
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
// If it's a team form, the target must be team webhook
// If it's a user form, the target must be user webhook
const isTeamForm = form.teamId;
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
}

View File

@ -4,7 +4,7 @@ import { expect } from "@playwright/test";
import { import {
addForm as addRoutingForm, addForm as addRoutingForm,
addOneFieldAndDescriptionAndSaveForm, addOneFieldAndDescriptionAndSaveForm,
} from "@calcom/app-store/routing-forms/playwright/tests/basic.e2e"; } from "@calcom/app-store/routing-forms/playwright/tests/testUtils";
import { CAL_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures";

View File

@ -122,24 +122,37 @@ const sendPayload = async (
}); });
} }
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, contentType); return _sendPayload(secretKey, webhook, body, contentType);
}; };
export const sendGenericWebhookPayload = async ( export const sendGenericWebhookPayload = async ({
secretKey: string | null, secretKey,
triggerEvent: string, triggerEvent,
createdAt: string, createdAt,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">, webhook,
data: Record<string, unknown> data,
) => { rootData,
const body = JSON.stringify(data); }: {
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, "application/json"); secretKey: string | null;
triggerEvent: string;
createdAt: string;
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">;
data: Record<string, unknown>;
rootData?: Record<string, unknown>;
}) => {
const body = JSON.stringify({
// Added rootData props first so that using the known(i.e. triggerEvent, createdAt, payload) properties in rootData doesn't override the known properties
...rootData,
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: data,
});
return _sendPayload(secretKey, webhook, body, "application/json");
}; };
const _sendPayload = async ( const _sendPayload = async (
secretKey: string | null, secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">, webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
body: string, body: string,
contentType: "application/json" | "application/x-www-form-urlencoded" contentType: "application/json" | "application/x-www-form-urlencoded"

View File

@ -63,8 +63,8 @@ function Component({ webhookId }: { webhookId: string }) {
backButton backButton
/> />
<WebhookForm <WebhookForm
noRoutingFormTriggers={false}
webhook={webhook} webhook={webhook}
noRoutingFormTriggers={!!webhook.teamId}
onSubmit={(values: WebhookFormSubmitData) => { onSubmit={(values: WebhookFormSubmitData) => {
if ( if (
subscriberUrlReserved({ subscriberUrlReserved({

View File

@ -81,9 +81,9 @@ const NewWebhookView = () => {
backButton backButton
/> />
<WebhookForm <WebhookForm
noRoutingFormTriggers={false}
onSubmit={onCreateWebhook} onSubmit={onCreateWebhook}
apps={installedApps?.items.map((app) => app.slug)} apps={installedApps?.items.map((app) => app.slug)}
noRoutingFormTriggers={!!teamId}
/> />
</> </>
); );