diff --git a/apps/web/playwright/fixtures/routingForms.ts b/apps/web/playwright/fixtures/routingForms.ts new file mode 100644 index 0000000000..751503e315 --- /dev/null +++ b/apps/web/playwright/fixtures/routingForms.ts @@ -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, + })), + }, + }); + }, + }; +}; diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index b8c6dc4e88..3d8fb05490 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; +import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; import { createUsersFixture } from "../fixtures/users"; @@ -23,6 +24,7 @@ export interface Fixtures { servers: ReturnType; prisma: typeof prisma; emails?: API; + routingForms: ReturnType; } declare global { @@ -71,6 +73,9 @@ export const test = base.extend({ prisma: async ({}, use) => { await use(prisma); }, + routingForms: async ({}, use) => { + await use(createRoutingFormsFixture()); + }, emails: async ({}, use) => { if (IS_MAILHOG_ENABLED) { const mailhogAPI = mailhog(); diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index a56f5a425b..af0be0f580 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -1,3 +1,4 @@ +import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { test } from "./lib/fixtures"; @@ -9,6 +10,9 @@ import { gotoRoutingLink, } 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.describe("BOOKING_CREATED", async () => { @@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any 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.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-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.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-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.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", 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 user = await users.create(); + const user = await users.create(null, { + hasTeam: true, + }); 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`); // Add webhook await page.fill('[name="subscriberUrl"]', webhookReceiver.url); await page.fill('[name="secret"]', "secret"); - await Promise.all([page.click("[type=submit]"), page.goForward()]); + await page.click("[type=submit]"); // Page contains the url expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); 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 formId = new URL(url).pathname.split("/").at(-1); + const form = await routingForms.create({ + 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"]'); 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: 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(); }); }); + +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; +} diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index 5878906607..00b32083f6 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -5,6 +5,13 @@ import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures"; import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils"; +import { + addForm, + saveCurrentForm, + verifySelectOptions, + addOneFieldAndDescriptionAndSaveForm, +} from "./testUtils"; + function todo(title: string) { // eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function test.skip(title, () => {}); @@ -407,22 +414,6 @@ async function fillSeededForm(page: Page, routingFormId: string) { 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( formId: string, 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({ page, 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 } = {}) { await selectOption({ selector: { @@ -581,8 +512,3 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) { page, }); } - -async function saveCurrentForm(page: Page) { - await page.click('[data-testid="update-form"]'); - await page.waitForSelector(".data-testid-toast-success"); -} diff --git a/packages/app-store/routing-forms/playwright/tests/testUtils.ts b/packages/app-store/routing-forms/playwright/tests/testUtils.ts new file mode 100644 index 0000000000..03220258d1 --- /dev/null +++ b/packages/app-store/routing-forms/playwright/tests/testUtils.ts @@ -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, + }; +} diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 16741cb299..4aebde0c7f 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -12,32 +12,50 @@ export async function onFormSubmission( form: Ensure & { user: User }, "fields">, response: Response ) { - const fieldResponsesByName: Record = {}; + const fieldResponsesByName: Record< + string, + { + value: Response[keyof Response]["value"]; + } + > = {}; for (const [fieldId, fieldResponse] of Object.entries(response)) { // Use the label lowercased as the key to identify a field. const key = form.fields.find((f) => f.id === fieldId)?.identifier || (fieldResponse.label as keyof typeof fieldResponsesByName); - fieldResponsesByName[key] = fieldResponse.value; + fieldResponsesByName[key] = { + value: fieldResponse.value, + }; } const subscriberOptions = { - userId: form.user.id, triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, - // When team routing forms are implemented, we need to make sure to add the teamId here - teamId: null, + ...getWebhookTargetEntity(form), }; const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { - sendGenericWebhookPayload( - webhook.secret, - "FORM_SUBMITTED", - new Date().toISOString(), + sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent: "FORM_SUBMITTED", + createdAt: new Date().toISOString(), webhook, - fieldResponsesByName - ).catch((e) => { + data: { + 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), + }, + }).catch((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); } }; + +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 }; +} diff --git a/packages/app-store/typeform/playwright/tests/basic.e2e.ts b/packages/app-store/typeform/playwright/tests/basic.e2e.ts index 406d58e19d..3d04e0df12 100644 --- a/packages/app-store/typeform/playwright/tests/basic.e2e.ts +++ b/packages/app-store/typeform/playwright/tests/basic.e2e.ts @@ -4,7 +4,7 @@ import { expect } from "@playwright/test"; import { addForm as addRoutingForm, 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 type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures"; diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index 402f42bb5f..200f6b0939 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -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 ( - 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"); +export const sendGenericWebhookPayload = async ({ + secretKey, + triggerEvent, + createdAt, + webhook, + data, + rootData, +}: { + secretKey: string | null; + triggerEvent: string; + createdAt: string; + webhook: Pick; + data: Record; + rootData?: Record; +}) => { + 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 ( secretKey: string | null, - triggerEvent: string, - createdAt: string, webhook: Pick, body: string, contentType: "application/json" | "application/x-www-form-urlencoded" diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 0bf8b35ba0..9429b5154a 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -63,8 +63,8 @@ function Component({ webhookId }: { webhookId: string }) { backButton /> { if ( subscriberUrlReserved({ diff --git a/packages/features/webhooks/pages/webhook-new-view.tsx b/packages/features/webhooks/pages/webhook-new-view.tsx index c7da3b97ad..bccddf531e 100644 --- a/packages/features/webhooks/pages/webhook-new-view.tsx +++ b/packages/features/webhooks/pages/webhook-new-view.tsx @@ -81,9 +81,9 @@ const NewWebhookView = () => { backButton /> app.slug)} - noRoutingFormTriggers={!!teamId} /> );