From d4380ee43f8276bed7b20d71be7dc79eca7a2594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Thu, 27 Jun 2024 15:54:55 +0200 Subject: [PATCH] feat: booking no show webhook (#15502) * WIP Signed-off-by: zomars * WIP Signed-off-by: zomars * WIP Signed-off-by: zomars * Type fixes * Update webhook.e2e.ts * Update noShow.handler.ts * Log failed results * Updates tests * Show generic error on frontend. * test: add basic webhook service test * fix: webhook end to end test * fix: type error * fix: test --------- Signed-off-by: zomars Co-authored-by: sean-brydon Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Udit Takkar --- .../components/booking/BookingListItem.tsx | 31 ++-- apps/web/playwright/bookings-list.e2e.ts | 21 ++- apps/web/playwright/fixtures/webhooks.ts | 39 +++++ apps/web/playwright/lib/fixtures.ts | 6 + apps/web/playwright/webhook.e2e.ts | 138 +++-------------- apps/web/public/static/locales/en/common.json | 1 + .../webhooks/components/WebhookForm.tsx | 1 + .../features/webhooks/lib/WebhookService.ts | 40 +++++ packages/features/webhooks/lib/constants.ts | 1 + packages/features/webhooks/lib/sendPayload.ts | 7 + .../webhooks/lib/test/WebhookService.test.ts | 141 ++++++++++++++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../routers/publicViewer/noShow.handler.ts | 98 +++++++++--- 14 files changed, 371 insertions(+), 156 deletions(-) create mode 100644 apps/web/playwright/fixtures/webhooks.ts create mode 100644 packages/features/webhooks/lib/WebhookService.ts create mode 100644 packages/features/webhooks/lib/test/WebhookService.test.ts create mode 100644 packages/prisma/migrations/20240619195146_add_booking_no_show_updated_webhook/migration.sql diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 2a8c220d57ec43..b9b18248152e09 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { useState } from "react"; -import { useForm, Controller, useFieldArray } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations"; import { @@ -31,20 +31,20 @@ import { DialogClose, DialogContent, DialogFooter, + Dropdown, + DropdownItem, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, Icon, MeetingTimeInTimezones, showToast, TableActions, TextAreaField, Tooltip, - Dropdown, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, - DropdownItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuCheckboxItem, } from "@calcom/ui"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; @@ -691,13 +691,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { const { copyToClipboard, isCopied } = useCopy(); const noShowMutation = trpc.viewer.public.noShow.useMutation({ - onSuccess: async () => { - showToast( - t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", { - x: name || email, - }), - "success" - ); + onSuccess: async (data) => { + showToast(t(data.message, { x: name || email }), "success"); }, onError: (err) => { showToast(err.message, "error"); @@ -804,8 +799,8 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { }); const { t } = useLocale(); const noShowMutation = trpc.viewer.public.noShow.useMutation({ - onSuccess: async () => { - showToast(t("no_show_updated"), "success"); + onSuccess: async (data) => { + showToast(t(data.message), "success"); }, onError: (err) => { showToast(err.message, "error"); diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 818d51e329f6a1..3cffeda1cb21f2 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -63,7 +63,7 @@ test.describe("Bookings", () => { }); }); test.describe("Past bookings", () => { - test("Mark first guest as no-show", async ({ page, users, bookings }) => { + test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => { const firstUser = await users.create(); const secondUser = await users.create(); @@ -81,8 +81,8 @@ test.describe("Bookings", () => { ], }); const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self(); - await firstUser.apiLogin(); + const webhookReceiver = await webhooks.createReceiver(); await page.goto(`/bookings/past`); const pastBookings = page.locator('[data-testid="past-bookings"]'); const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0); @@ -95,6 +95,23 @@ test.describe("Bookings", () => { await firstGuest.click(); await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible(); await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden(); + await webhookReceiver.waitForRequestCount(1); + const [request] = webhookReceiver.requestList; + const body = request.body; + // remove dynamic properties that differs depending on where you run the tests + const dynamic = "[redacted/dynamic]"; + // @ts-expect-error we are modifying the object + body.createdAt = dynamic; + expect(body).toMatchObject({ + triggerEvent: "BOOKING_NO_SHOW_UPDATED", + createdAt: "[redacted/dynamic]", + payload: { + message: "first@cal.com marked as no-show", + attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }], + bookingUid: bookingWhereFirstUserIsOrganizer?.uid, + }, + }); + webhookReceiver.close(); }); test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => { const firstUser = await users.create(); diff --git a/apps/web/playwright/fixtures/webhooks.ts b/apps/web/playwright/fixtures/webhooks.ts new file mode 100644 index 00000000000000..de5b0b37fcba8f --- /dev/null +++ b/apps/web/playwright/fixtures/webhooks.ts @@ -0,0 +1,39 @@ +import { expect, type Page } from "@playwright/test"; + +import { createHttpServer } from "../lib/testUtils"; + +export function createWebhookPageFixture(page: Page) { + return { + createTeamReceiver: async () => { + const webhookReceiver = createHttpServer(); + await page.goto(`/settings/developer/webhooks`); + 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; + await page.click('[data-testid="new_webhook"]'); + await page.fill('[name="subscriberUrl"]', webhookReceiver.url); + await page.fill('[name="secret"]', "secret"); + await Promise.all([ + page.click("[type=submit]"), + page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), + ]); + expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); + return { webhookReceiver, teamId }; + }, + createReceiver: async () => { + const webhookReceiver = createHttpServer(); + await page.goto(`/settings/developer/webhooks`); + await page.click('[data-testid="new_webhook"]'); + await page.fill('[name="subscriberUrl"]', webhookReceiver.url); + await page.fill('[name="secret"]', "secret"); + await Promise.all([ + page.click("[type=submit]"), + page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), + ]); + expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); + return webhookReceiver; + }, + }; +} diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 88468aea0c14dc..91e811e478db84 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -16,6 +16,7 @@ import { createBookingPageFixture } from "../fixtures/regularBookings"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; import { createUsersFixture } from "../fixtures/users"; +import { createWebhookPageFixture } from "../fixtures/webhooks"; import { createWorkflowPageFixture } from "../fixtures/workflows"; export interface Fixtures { @@ -34,6 +35,7 @@ export interface Fixtures { features: ReturnType; eventTypePage: ReturnType; appsPage: ReturnType; + webhooks: ReturnType; } declare global { @@ -110,4 +112,8 @@ export const test = base.extend({ const appsPage = createAppsFixture(page); await use(appsPage); }, + webhooks: async ({ page }, use) => { + const webhooks = createWebhookPageFixture(page); + await use(webhooks); + }, }); diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index f02556a7067fea..11dad203e9ab61 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -1,4 +1,3 @@ -import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { v4 as uuidv4 } from "uuid"; @@ -10,7 +9,6 @@ import { test } from "./lib/fixtures"; import { bookOptinEvent, bookTimeSlot, - createHttpServer, createUserWithSeatedEventAndAttendees, gotoRoutingLink, selectFirstAvailableTimeSlotNextMonth, @@ -24,54 +22,16 @@ test.afterEach(async ({ users }) => { await users.deleteAll(); }); -async function createWebhookReceiver(page: Page) { - const webhookReceiver = createHttpServer(); - - await page.goto(`/settings/developer/webhooks`); - - // --- add webhook - await page.click('[data-testid="new_webhook"]'); - - await page.fill('[name="subscriberUrl"]', webhookReceiver.url); - - await page.fill('[name="secret"]', "secret"); - - await Promise.all([ - page.click("[type=submit]"), - page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), - ]); - - // page contains the url - expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); - - return webhookReceiver; -} - test.describe("BOOKING_CREATED", async () => { test("add webhook & test that creating an event triggers a webhook call", async ({ page, users, + webhooks, }, _testInfo) => { - const webhookReceiver = createHttpServer(); const user = await users.create(); const [eventType] = user.eventTypes; await user.apiLogin(); - await page.goto(`/settings/developer/webhooks`); - - // --- add webhook - await page.click('[data-testid="new_webhook"]'); - - await page.fill('[name="subscriberUrl"]', webhookReceiver.url); - - await page.fill('[name="secret"]', "secret"); - - await Promise.all([ - page.click("[type=submit]"), - page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), - ]); - - // page contains the url - expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); + const webhookReceiver = await webhooks.createReceiver(); // --- Book the first available day next month in the pro user's "30min"-event await page.goto(`/${user.username}/${eventType.slug}`); @@ -169,8 +129,8 @@ test.describe("BOOKING_REJECTED", async () => { test("can book an event that requires confirmation and then that booking can be rejected by organizer", async ({ page, users, + webhooks, }) => { - const webhookReceiver = createHttpServer(); // --- create a user const user = await users.create(); @@ -182,24 +142,7 @@ test.describe("BOOKING_REJECTED", async () => { // --- login as that user await user.apiLogin(); - - await page.goto(`/settings/developer/webhooks`); - - // --- add webhook - await page.click('[data-testid="new_webhook"]'); - - await page.fill('[name="subscriberUrl"]', webhookReceiver.url); - - await page.fill('[name="secret"]', "secret"); - - await Promise.all([ - page.click("[type=submit]"), - page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), - ]); - - // page contains the url - expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); - + const webhookReceiver = await webhooks.createReceiver(); await page.goto("/bookings/unconfirmed"); await page.click('[data-testid="reject"]'); await page.click('[data-testid="rejection-confirm"]'); @@ -293,30 +236,14 @@ test.describe("BOOKING_REQUESTED", async () => { test("can book an event that requires confirmation and get a booking requested event", async ({ page, users, + webhooks, }) => { - const webhookReceiver = createHttpServer(); // --- create a user const user = await users.create(); // --- login as that user await user.apiLogin(); - - await page.goto(`/settings/developer/webhooks`); - - // --- add webhook - await page.click('[data-testid="new_webhook"]'); - - await page.fill('[name="subscriberUrl"]', webhookReceiver.url); - - await page.fill('[name="secret"]', "secret"); - - await Promise.all([ - page.click("[type=submit]"), - page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")), - ]); - - // page contains the url - expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); + const webhookReceiver = await webhooks.createReceiver(); // --- visit user page await page.goto(`/${user.username}`); @@ -410,13 +337,18 @@ test.describe("BOOKING_REQUESTED", async () => { }); test.describe("BOOKING_RESCHEDULED", async () => { - test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => { + test("can reschedule a booking and get a booking rescheduled event", async ({ + page, + users, + bookings, + webhooks, + }) => { const user = await users.create(); const [eventType] = user.eventTypes; await user.apiLogin(); - const webhookReceiver = await createWebhookReceiver(page); + const webhookReceiver = await webhooks.createReceiver(); const booking = await bookings.create(user.id, user.username, eventType.id, { status: BookingStatus.ACCEPTED, @@ -451,6 +383,7 @@ test.describe("BOOKING_RESCHEDULED", async () => { page, users, bookings, + webhooks, }) => { const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, @@ -464,7 +397,7 @@ test.describe("BOOKING_RESCHEDULED", async () => { await user.apiLogin(); - const webhookReceiver = await createWebhookReceiver(page); + const webhookReceiver = await webhooks.createReceiver(); const bookingAttendees = await prisma.attendee.findMany({ where: { bookingId: booking.id }, @@ -670,24 +603,11 @@ test.describe("MEETING_ENDED, MEETING_STARTED", async () => { }); test.describe("FORM_SUBMITTED", async () => { - test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => { - const webhookReceiver = createHttpServer(); - const user = await users.create(null, { - hasTeam: true, - }); + test("on submitting user form, triggers user webhook", async ({ page, users, routingForms, webhooks }) => { + const user = await users.create(); await user.apiLogin(); - - await page.goto(`/settings/developer/webhooks/new`); - - // Add webhook - await page.fill('[name="subscriberUrl"]', webhookReceiver.url); - await page.fill('[name="secret"]', "secret"); - await page.click("[type=submit]"); - - // Page contains the url - expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); - + const webhookReceiver = await webhooks.createReceiver(); await page.waitForLoadState("networkidle"); const form = await routingForms.create({ @@ -736,21 +656,12 @@ test.describe("FORM_SUBMITTED", async () => { webhookReceiver.close(); }); - test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => { - const webhookReceiver = createHttpServer(); + test("on submitting team form, triggers team webhook", async ({ page, users, routingForms, webhooks }) => { 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 { webhookReceiver, teamId } = await webhooks.createTeamReceiver(); const form = await routingForms.create({ name: "Test Form", userId: user.id, @@ -797,12 +708,3 @@ test.describe("FORM_SUBMITTED", async () => { 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/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4751ce87794171..b18fbaa399ced6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -471,6 +471,7 @@ "meeting_ended": "Meeting Ended", "form_submitted": "Form Submitted", "booking_paid": "Booking Paid", + "booking_no_show_updated": "Booking No-Show Updated", "event_triggers": "Event Triggers", "subscriber_url": "Subscriber URL", "create_new_webhook": "Create a new webhook", diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index b64f5f1136f9e5..90116704c7021b 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -38,6 +38,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record[0]; + private webhooks: Awaited> = []; + constructor(options: Parameters[0]) { + return (async (): Promise => { + this.options = options; + this.webhooks = await getWebhooks(options); + return this; + })() as unknown as WebhookService; + } + async getWebhooks() { + return this.webhooks; + } + async sendPayload(payload: Parameters[4]) { + const promises = this.webhooks.map((sub) => + sendOrSchedulePayload( + sub.secret, + this.options.triggerEvent, + new Date().toISOString(), + sub, + payload + ).catch((e) => { + log.error( + `Error executing webhook for event: ${this.options.triggerEvent}, URL: ${sub.subscriberUrl}`, + safeStringify(e) + ); + }) + ); + await Promise.allSettled(promises); + } +} diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts index 317e88e16e2f1e..ac4f3b79fd4f02 100644 --- a/packages/features/webhooks/lib/constants.ts +++ b/packages/features/webhooks/lib/constants.ts @@ -15,6 +15,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { WebhookTriggerEvents.BOOKING_REJECTED, WebhookTriggerEvents.RECORDING_READY, WebhookTriggerEvents.INSTANT_MEETING, + WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, ] as const, "routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const, }; diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index d0670b0153d08a..8179cc2e3629b7 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -29,7 +29,14 @@ export type WithUTCOffsetType = T & { attendees?: (Person & UTCOffset)[]; }; +export type BookingNoShowUpdatedPayload = { + message: string; + bookingUid: string; + attendees: { email: string; noShow: boolean }[]; +}; + export type WebhookDataType = CalendarEvent & + // BookingNoShowUpdatedPayload & // This breaks all other webhooks EventTypeInfo & { metadata?: { [key: string]: string | number | boolean | null }; bookingId?: number; diff --git a/packages/features/webhooks/lib/test/WebhookService.test.ts b/packages/features/webhooks/lib/test/WebhookService.test.ts new file mode 100644 index 00000000000000..36e1039b9c5a0d --- /dev/null +++ b/packages/features/webhooks/lib/test/WebhookService.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import { WebhookService } from "../WebhookService"; +import getWebhooks from "../getWebhooks"; + +vi.mock("../getWebhooks"); +vi.mock("../sendOrSchedulePayload"); + +vi.mock("@calcom/lib/logger", async () => { + const actual = await vi.importActual("@calcom/lib/logger"); + return { + ...actual, + getSubLogger: vi.fn(() => ({ + error: vi.fn(), + })), + }; +}); + +vi.mock("@calcom/lib/safeStringify", () => ({ + safeStringify: JSON.stringify, +})); + +describe("WebhookService", () => { + const mockOptions = { + id: "mockOptionsId", + subscriberUrl: "subUrl", + payloadTemplate: "PayloadTemplate", + appId: "AppId", + secret: "WhSecret", + triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, + }; + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should initialize with options and webhooks", async () => { + const mockWebhooks = [ + { + id: "webhookId", + subscriberUrl: "url", + secret: "secret", + appId: "appId", + payloadTemplate: "payloadTemplate", + }, + ]; + vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks); + + // Has to be called with await due to the iffi being async + const service = await new WebhookService(mockOptions); + + expect(service).toBeInstanceOf(WebhookService); + expect(await service.getWebhooks()).toEqual(mockWebhooks); + expect(getWebhooks).toHaveBeenCalledWith(mockOptions); + }); + + // it("should send payload to all webhooks", async () => { + // const mockWebhooks = [ + // { + // id: "webhookId", + // subscriberUrl: "url", + // secret: "secret", + // appId: "appId", + // payloadTemplate: "payloadTemplate", + // }, + // { + // id: "webhookId2", + // subscriberUrl: "url", + // secret: "secret2", + // appId: "appId2", + // payloadTemplate: "payloadTemplate", + // }, + // ]; + // vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks); + // const service = await new WebhookService(mockOptions); + // + // const payload = { + // secretKey: "secret", + // triggerEvent: "triggerEvent", + // createdAt: "now", + // webhook: { + // subscriberUrl: "url", + // appId: "appId", + // payloadTemplate: "payloadTemplate", + // }, + // data: "test", + // }; + // + // await service.sendPayload(payload as any); + // + // expect(sendOrSchedulePayload).toHaveBeenCalledTimes(mockWebhooks.length); + // + // mockWebhooks.forEach((webhook) => { + // expect(sendOrSchedulePayload).toHaveBeenCalledWith( + // webhook.secret, + // mockOptions.triggerEvent, + // expect.any(String), + // webhook, + // payload + // ); + // }); + // }); + // + // it("should log error when sending payload fails", async () => { + // const mockWebhooks = [ + // { + // id: "webhookId", + // subscriberUrl: "url", + // secret: "secret", + // appId: "appId", + // payloadTemplate: "payloadTemplate", + // }, + // ]; + // vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks); + // + // const logError = vi.fn(); + // + // (sendOrSchedulePayload as any).mockImplementation(() => { + // throw new Error("Failure"); + // }); + // + // const service = new WebhookService(mockOptions); + // + // const payload = { + // secretKey: "secret", triggerEvent: "triggerEvent", createdAt: "now", webhook: { + // subscriberUrl: "url", + // appId: "appId", + // payloadTemplate: "payloadTemplate" + // }, + // data: "test" + // }; + // + // await service.sendPayload(payload as any); + // + // expect(logError).toHaveBeenCalledWith( + // `Error executing webhook for event: ${mockOptions.triggerEvent}, URL: ${mockWebhooks[0].subscriberUrl}`, + // JSON.stringify(new Error("Failure")) + // ); + // }); +}); diff --git a/packages/prisma/migrations/20240619195146_add_booking_no_show_updated_webhook/migration.sql b/packages/prisma/migrations/20240619195146_add_booking_no_show_updated_webhook/migration.sql new file mode 100644 index 00000000000000..43d31aa5293ed6 --- /dev/null +++ b/packages/prisma/migrations/20240619195146_add_booking_no_show_updated_webhook/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9df2e32a9ba529..9bbd25a23d306a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -707,6 +707,7 @@ enum WebhookTriggerEvents { BOOKING_REQUESTED BOOKING_CANCELLED BOOKING_REJECTED + BOOKING_NO_SHOW_UPDATED FORM_SUBMITTED MEETING_ENDED MEETING_STARTED diff --git a/packages/trpc/server/routers/publicViewer/noShow.handler.ts b/packages/trpc/server/routers/publicViewer/noShow.handler.ts index d854ed97266313..7f4dbd64ec202a 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/noShow.handler.ts @@ -1,5 +1,9 @@ +import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; +import { WebhookTriggerEvents } from "@calcom/prisma/client"; import type { TNoShowInputSchema } from "./noShow.schema"; @@ -7,6 +11,24 @@ type NoShowOptions = { input: TNoShowInputSchema; }; +const getResultPayload = async (attendees: { email: string; noShow: boolean }[]) => { + if (attendees.length === 1) { + const [attendee] = attendees; + return { + message: attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", + attendees: [attendee], + }; + } + return { message: "no_show_updated", attendees: attendees }; +}; + +const logFailedResults = (results: PromiseSettledResult[]) => { + const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[]; + if (failed.length < 1) return; + const failedMessage = failed.map((r) => r.reason); + console.error("Failed to update no-show status", failedMessage.join(",")); +}; + export const noShowHandler = async ({ input }: NoShowOptions) => { const { bookingUid, attendees } = input; @@ -31,33 +53,73 @@ export const noShowHandler = async ({ input }: NoShowOptions) => { email: true, }, }); - + const allAttendeesMap = allAttendees.reduce((acc, attendee) => { + acc[attendee.email] = attendee; + return acc; + }, {} as Record); const updatePromises = attendees.map((attendee) => { - const attendeeToUpdate = allAttendees.find((a) => a.email === attendee.email); - - if (attendeeToUpdate) { - return prisma.attendee.update({ - where: { id: attendeeToUpdate.id }, - data: { noShow: attendee.noShow }, - }); - } + const attendeeToUpdate = allAttendeesMap[attendee.email]; + if (!attendeeToUpdate) return; + return prisma.attendee.update({ + where: { id: attendeeToUpdate.id }, + data: { noShow: attendee.noShow }, + }); }); - - await Promise.all(updatePromises); - } else { - await prisma.booking.update({ - where: { - uid: bookingUid, - }, - data: { - noShowHost: true, + const results = await Promise.allSettled(updatePromises); + logFailedResults(results); + const _attendees = results + .filter((x) => x.status === "fulfilled") + .map((x) => (x as PromiseFulfilledResult<{ noShow: boolean; email: string }>).value) + .map((x) => ({ email: x.email, noShow: x.noShow })); + const payload = await getResultPayload(_attendees); + const booking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + eventType: { + select: { + id: true, + teamId: true, + userId: true, + }, + }, }, }); + const orgId = await getOrgIdFromMemberOrTeamId({ + memberId: booking?.eventType?.userId, + teamId: booking?.eventType?.teamId, + }); + const webhooks = await new WebhookService({ + teamId: booking?.eventType?.teamId, + userId: booking?.eventType?.userId, + eventTypeId: booking?.eventType?.id, + orgId, + triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, + }); + + const t = await getTranslation("en", "common"); + await webhooks.sendPayload({ + ...payload, + /** We send webhook message pre-translated, on client we already handle this */ + // @ts-expect-error payload is too booking specific, we need to refactor this + message: t(payload.message, { x: payload.attendees[0]?.email || "User" }), + bookingUid, + }); + return payload; } + await prisma.booking.update({ + where: { + uid: bookingUid, + }, + data: { + noShowHost: true, + }, + }); + return { message: "No-show status updated", noShowHost: true }; } catch (error) { if (error instanceof Error) { logger.error(error.message); } + return { message: "Failed to update no-show status" }; } };