Skip to content

Commit 9b4c201

Browse files
committed
WIP
Signed-off-by: zomars <zomars@me.com>
1 parent 9a473d5 commit 9b4c201

File tree

7 files changed

+228
-37
lines changed

7 files changed

+228
-37
lines changed

apps/web/components/booking/BookingListItem.tsx

+13-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Link from "next/link";
22
import { useState } from "react";
3-
import { useForm, Controller, useFieldArray } from "react-hook-form";
3+
import { Controller, useFieldArray, useForm } from "react-hook-form";
44

55
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
66
import {
@@ -31,20 +31,20 @@ import {
3131
DialogClose,
3232
DialogContent,
3333
DialogFooter,
34+
Dropdown,
35+
DropdownItem,
36+
DropdownMenuCheckboxItem,
37+
DropdownMenuContent,
38+
DropdownMenuItem,
39+
DropdownMenuLabel,
40+
DropdownMenuSeparator,
41+
DropdownMenuTrigger,
3442
Icon,
3543
MeetingTimeInTimezones,
3644
showToast,
3745
TableActions,
3846
TextAreaField,
3947
Tooltip,
40-
Dropdown,
41-
DropdownMenuContent,
42-
DropdownMenuTrigger,
43-
DropdownMenuItem,
44-
DropdownItem,
45-
DropdownMenuLabel,
46-
DropdownMenuSeparator,
47-
DropdownMenuCheckboxItem,
4848
} from "@calcom/ui";
4949

5050
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
@@ -691,13 +691,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
691691
const { copyToClipboard, isCopied } = useCopy();
692692

693693
const noShowMutation = trpc.viewer.public.noShow.useMutation({
694-
onSuccess: async () => {
695-
showToast(
696-
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
697-
x: name || email,
698-
}),
699-
"success"
700-
);
694+
onSuccess: async (data) => {
695+
showToast(t(data.message, { x: name || email }), "success");
701696
},
702697
onError: (err) => {
703698
showToast(err.message, "error");
@@ -804,8 +799,8 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
804799
});
805800
const { t } = useLocale();
806801
const noShowMutation = trpc.viewer.public.noShow.useMutation({
807-
onSuccess: async () => {
808-
showToast(t("no_show_updated"), "success");
802+
onSuccess: async (data) => {
803+
showToast(t(data.message), "success");
809804
},
810805
onError: (err) => {
811806
showToast(err.message, "error");

apps/web/playwright/bookings-list.e2e.ts

+1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ test.describe("Bookings", () => {
8484

8585
await firstUser.apiLogin();
8686
await page.goto(`/bookings/past`);
87+
await page.pause();
8788
const pastBookings = page.locator('[data-testid="past-bookings"]');
8889
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
8990
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');

apps/web/playwright/webhook.e2e.ts

+117
Original file line numberDiff line numberDiff line change
@@ -806,3 +806,120 @@ async function clickFirstTeamWebhookCta(page: Page) {
806806
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
807807
return teamId;
808808
}
809+
810+
test.describe("BOOKING_NO_SHOW_UPDATED", async () => {
811+
test("on marking an attendee as no-show, triggers webhook", async ({ page, users }) => {
812+
const webhookReceiver = createHttpServer();
813+
// --- create a user
814+
const user = await users.create();
815+
816+
// --- login as that user
817+
await user.apiLogin();
818+
819+
await page.goto(`/settings/developer/webhooks`);
820+
821+
// --- add webhook
822+
await page.click('[data-testid="new_webhook"]');
823+
824+
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
825+
826+
await page.fill('[name="secret"]', "secret");
827+
828+
await Promise.all([
829+
page.click("[type=submit]"),
830+
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
831+
]);
832+
833+
// page contains the url
834+
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
835+
836+
// --- visit user page
837+
await page.goto(`/${user.username}`);
838+
839+
// --- mark the user's attendee as no-show
840+
// await bookOptinEvent(page);
841+
842+
// --- check that webhook was called
843+
844+
await webhookReceiver.waitForRequestCount(1);
845+
846+
const [request] = webhookReceiver.requestList;
847+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
848+
const body = request.body as any;
849+
850+
body.createdAt = dynamic;
851+
body.payload.startTime = dynamic;
852+
body.payload.endTime = dynamic;
853+
body.payload.location = dynamic;
854+
for (const attendee of body.payload.attendees) {
855+
attendee.timeZone = dynamic;
856+
attendee.language = dynamic;
857+
}
858+
body.payload.organizer.id = dynamic;
859+
body.payload.organizer.email = dynamic;
860+
body.payload.organizer.timeZone = dynamic;
861+
body.payload.organizer.language = dynamic;
862+
body.payload.uid = dynamic;
863+
body.payload.bookingId = dynamic;
864+
body.payload.additionalInformation = dynamic;
865+
body.payload.requiresConfirmation = dynamic;
866+
body.payload.eventTypeId = dynamic;
867+
body.payload.videoCallData = dynamic;
868+
body.payload.appsStatus = dynamic;
869+
body.payload.metadata.videoCallUrl = dynamic;
870+
871+
expect(body).toMatchObject({
872+
triggerEvent: "BOOKING_REQUESTED",
873+
createdAt: "[redacted/dynamic]",
874+
payload: {
875+
type: "opt-in",
876+
title: "Opt in between Nameless and Test Testson",
877+
customInputs: {},
878+
startTime: "[redacted/dynamic]",
879+
endTime: "[redacted/dynamic]",
880+
organizer: {
881+
id: "[redacted/dynamic]",
882+
name: "Nameless",
883+
email: "[redacted/dynamic]",
884+
timeZone: "[redacted/dynamic]",
885+
language: "[redacted/dynamic]",
886+
},
887+
responses: {
888+
email: {
889+
value: "test@example.com",
890+
label: "email_address",
891+
},
892+
name: {
893+
value: "Test Testson",
894+
label: "your_name",
895+
},
896+
},
897+
userFieldsResponses: {},
898+
attendees: [
899+
{
900+
email: "test@example.com",
901+
name: "Test Testson",
902+
timeZone: "[redacted/dynamic]",
903+
language: "[redacted/dynamic]",
904+
},
905+
],
906+
location: "[redacted/dynamic]",
907+
destinationCalendar: null,
908+
requiresConfirmation: "[redacted/dynamic]",
909+
eventTypeId: "[redacted/dynamic]",
910+
uid: "[redacted/dynamic]",
911+
eventTitle: "Opt in",
912+
eventDescription: null,
913+
price: 0,
914+
currency: "usd",
915+
length: 30,
916+
bookingId: "[redacted/dynamic]",
917+
status: "PENDING",
918+
additionalInformation: "[redacted/dynamic]",
919+
metadata: { videoCallUrl: "[redacted/dynamic]" },
920+
},
921+
});
922+
923+
webhookReceiver.close();
924+
});
925+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import sendOrSchedulePayload from "webhooks/lib/sendOrSchedulePayload";
2+
3+
import logger from "@calcom/lib/logger";
4+
import { WebhookTriggerEvents } from "@calcom/prisma/client";
5+
6+
import getWebhooks from "./getWebhooks";
7+
8+
const log = logger.getSubLogger({ prefix: ["[WebhookService] "] });
9+
10+
export type GetSubscriberOptions = {
11+
userId?: number | null;
12+
eventTypeId?: number | null;
13+
triggerEvent: WebhookTriggerEvents;
14+
teamId?: number | null;
15+
orgId?: number | null;
16+
};
17+
18+
/** This is a WIP. With minimal methods until the API matures and stabilizes */
19+
export class WebhookService {
20+
private options = {} as GetSubscriberOptions;
21+
private webhooks: Awaited<ReturnType<typeof getWebhooks>> = [];
22+
constructor(init: GetSubscriberOptions) {
23+
return (async (): Promise<WebhookService> => {
24+
this.options = init;
25+
this.webhooks = await getWebhooks(init);
26+
return this;
27+
})() as unknown as WebhookService;
28+
}
29+
async getWebhooks() {
30+
return this.webhooks;
31+
}
32+
async sendPayload(payload: WebhookPayload) {
33+
const promises = this.webhooks.map((sub) =>
34+
sendOrSchedulePayload(
35+
sub.secret,
36+
this.options.triggerEvent,
37+
new Date().toISOString(),
38+
sub,
39+
payload
40+
).catch((e) => {
41+
log.error(
42+
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_REQUESTED}, URL: ${sub.subscriberUrl}`,
43+
safeStringify(e)
44+
);
45+
})
46+
);
47+
await Promise.all(promises);
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterEnum
2+
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED';

packages/prisma/schema.prisma

+1
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ enum WebhookTriggerEvents {
706706
BOOKING_REQUESTED
707707
BOOKING_CANCELLED
708708
BOOKING_REJECTED
709+
BOOKING_NO_SHOW_UPDATED
709710
FORM_SUBMITTED
710711
MEETING_ENDED
711712
MEETING_STARTED

packages/trpc/server/routers/publicViewer/noShow.handler.ts

+45-19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService";
12
import logger from "@calcom/lib/logger";
23
import { prisma } from "@calcom/prisma";
34

@@ -7,6 +8,17 @@ type NoShowOptions = {
78
input: TNoShowInputSchema;
89
};
910

11+
const getResultPayload = async (attendees: { email: string; noShow: boolean }[]) => {
12+
if (attendees.length === 1) {
13+
const [attendee] = attendees;
14+
return {
15+
message: attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show",
16+
attendees: [attendee],
17+
};
18+
}
19+
return { message: "no_show_updated", attendees: attendees };
20+
};
21+
1022
export const noShowHandler = async ({ input }: NoShowOptions) => {
1123
const { bookingUid, attendees } = input;
1224

@@ -31,33 +43,47 @@ export const noShowHandler = async ({ input }: NoShowOptions) => {
3143
email: true,
3244
},
3345
});
34-
46+
const allAttendeesMap = allAttendees.reduce((acc, attendee) => {
47+
acc[attendee.email] = attendee;
48+
return acc;
49+
}, {} as Record<string, { id: number; email: string }>);
3550
const updatePromises = attendees.map((attendee) => {
36-
const attendeeToUpdate = allAttendees.find((a) => a.email === attendee.email);
37-
38-
if (attendeeToUpdate) {
39-
return prisma.attendee.update({
40-
where: { id: attendeeToUpdate.id },
41-
data: { noShow: attendee.noShow },
42-
});
43-
}
51+
const attendeeToUpdate = allAttendeesMap[attendee.email];
52+
if (!attendeeToUpdate) return;
53+
return prisma.attendee.update({
54+
where: { id: attendeeToUpdate.id },
55+
data: { noShow: attendee.noShow },
56+
});
4457
});
45-
46-
await Promise.all(updatePromises);
47-
} else {
48-
await prisma.booking.update({
49-
where: {
50-
uid: bookingUid,
51-
},
52-
data: {
53-
noShowHost: true,
54-
},
58+
const results = await Promise.allSettled(updatePromises);
59+
const _attendees = results
60+
.filter((x) => x.status === "fulfilled")
61+
.map((x) => (x as PromiseFulfilledResult<{ noShow: boolean; email: string }>).value)
62+
.map((x) => ({ email: x.email, noShow: x.noShow }));
63+
const payload = await getResultPayload(_attendees);
64+
// sendPayload(payload);
65+
const webhooks = await new WebhookService({
66+
triggerEvent: "BOOKING_NO_SHOW_UPDATED",
5567
});
68+
await webhooks.sendPayload(payload);
69+
return payload;
5670
}
71+
await prisma.booking.update({
72+
where: {
73+
uid: bookingUid,
74+
},
75+
data: {
76+
noShowHost: true,
77+
},
78+
});
79+
return { message: "No-show status updated", noShowHost: true };
5780
} catch (error) {
81+
let message = "Failed to update no-show status";
5882
if (error instanceof Error) {
5983
logger.error(error.message);
84+
message = error.message;
6085
}
86+
return { message };
6187
}
6288
};
6389

0 commit comments

Comments
 (0)