feat: booking no show webhook (#15502)
* WIP Signed-off-by: zomars <zomars@me.com> * WIP Signed-off-by: zomars <zomars@me.com> * WIP Signed-off-by: zomars <zomars@me.com> * 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 <zomars@me.com> Co-authored-by: sean-brydon <sean@cal.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
co-authored by
sean-brydon
Udit Takkar
Udit Takkar
parent
6207afdf03
commit
3948b8d203
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createFeatureFixture>;
|
||||
eventTypePage: ReturnType<typeof createEventTypeFixture>;
|
||||
appsPage: ReturnType<typeof createAppsFixture>;
|
||||
webhooks: ReturnType<typeof createWebhookPageFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -110,4 +112,8 @@ export const test = base.extend<Fixtures>({
|
||||
const appsPage = createAppsFixture(page);
|
||||
await use(appsPage);
|
||||
},
|
||||
webhooks: async ({ page }, use) => {
|
||||
const webhooks = createWebhookPageFixture(page);
|
||||
await use(webhooks);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -38,6 +38,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
|
||||
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
|
||||
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
|
||||
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
|
||||
{ value: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, label: "booking_no_show_updated" },
|
||||
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
|
||||
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
|
||||
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import getWebhooks from "./getWebhooks";
|
||||
import sendOrSchedulePayload from "./sendOrSchedulePayload";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[WebhookService] "] });
|
||||
|
||||
/** This is a WIP. With minimal methods until the API matures and stabilizes */
|
||||
export class WebhookService {
|
||||
private options = {} as Parameters<typeof getWebhooks>[0];
|
||||
private webhooks: Awaited<ReturnType<typeof getWebhooks>> = [];
|
||||
constructor(options: Parameters<typeof getWebhooks>[0]) {
|
||||
return (async (): Promise<WebhookService> => {
|
||||
this.options = options;
|
||||
this.webhooks = await getWebhooks(options);
|
||||
return this;
|
||||
})() as unknown as WebhookService;
|
||||
}
|
||||
async getWebhooks() {
|
||||
return this.webhooks;
|
||||
}
|
||||
async sendPayload(payload: Parameters<typeof sendOrSchedulePayload>[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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,14 @@ export type WithUTCOffsetType<T> = 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;
|
||||
|
||||
@@ -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<typeof import("@calcom/lib/logger")>("@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"))
|
||||
// );
|
||||
// });
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED';
|
||||
@@ -708,6 +708,7 @@ enum WebhookTriggerEvents {
|
||||
BOOKING_REQUESTED
|
||||
BOOKING_CANCELLED
|
||||
BOOKING_REJECTED
|
||||
BOOKING_NO_SHOW_UPDATED
|
||||
FORM_SUBMITTED
|
||||
MEETING_ENDED
|
||||
MEETING_STARTED
|
||||
|
||||
@@ -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<any>[]) => {
|
||||
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<string, { id: number; email: string }>);
|
||||
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" };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user