From e075dcdeaa1f30b7ea68e37633f62e3b449724e6 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:16:31 +0530 Subject: [PATCH] feat: create new markNoShow endpoint (#15738) * feat: create new markNoShow endpoint * chore: add schema * chore: update name * chore: save progress * refactor: improve code and types * fix: unit test * chore: improvements * chore: move query to booking repository * fix: type error --- .../controllers/bookings.controller.ts | 2 + .../bookings/outputs/mark-no-show.output.ts | 4 - .../components/booking/BookingListItem.tsx | 11 +- .../bookings/views/bookings-single-view.tsx | 4 +- apps/web/modules/test-setup.ts | 2 +- packages/features/handleMarkNoShow.ts | 251 +++++++++++------- packages/lib/server/repository/booking.ts | 44 +++ .../server/routers/loggedInViewer/_router.tsx | 14 + .../loggedInViewer/markNoShow.handler.ts | 23 ++ .../markNoShow.schema.ts} | 0 .../server/routers/publicViewer/_router.tsx | 9 +- ...handler.ts => markHostAsNoShow.handler.ts} | 6 +- .../publicViewer/markHostAsNoShow.schema.ts | 8 + 13 files changed, 263 insertions(+), 115 deletions(-) create mode 100644 packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts rename packages/trpc/server/routers/{publicViewer/noShow.schema.ts => loggedInViewer/markNoShow.schema.ts} (100%) rename packages/trpc/server/routers/publicViewer/{noShow.handler.ts => markHostAsNoShow.handler.ts} (55%) create mode 100644 packages/trpc/server/routers/publicViewer/markHostAsNoShow.schema.ts diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 5d6301d49f..63410c64d7 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -211,6 +211,7 @@ export class BookingsController { @Permissions([BOOKING_WRITE]) @UseGuards(ApiAuthGuard) async markNoShow( + @GetUser("id") userId: number, @Body() body: MarkNoShowInput, @Param("bookingUid") bookingUid: string ): Promise { @@ -219,6 +220,7 @@ export class BookingsController { bookingUid: bookingUid, attendees: body.attendees, noShowHost: body.noShowHost, + userId, }); return { status: SUCCESS_STATUS, data: markNoShowResponse }; diff --git a/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts b/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts index 5b1e5de68d..a321848b13 100644 --- a/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts +++ b/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts @@ -20,10 +20,6 @@ class HandleMarkNoShowData { @IsOptional() noShowHost?: boolean; - @IsString() - @IsOptional() - messageKey?: string; - @ValidateNested() @Type(() => Attendee) @IsArray() diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index b6dc5f00f7..c7925b9178 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -668,12 +668,9 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { const [openDropdown, setOpenDropdown] = useState(false); const { copyToClipboard, isCopied } = useCopy(); - const noShowMutation = trpc.viewer.public.noShow.useMutation({ + const noShowMutation = trpc.viewer.markNoShow.useMutation({ onSuccess: async (data) => { - showToast( - t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }), - "success" - ); + showToast(data.message, "success"); }, onError: (err) => { showToast(err.message, "error"); @@ -779,9 +776,9 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { }; }); const { t } = useLocale(); - const noShowMutation = trpc.viewer.public.noShow.useMutation({ + const noShowMutation = trpc.viewer.markNoShow.useMutation({ onSuccess: async (data) => { - showToast(t("messageKey" in data && data.messageKey ? data.messageKey : data.message), "success"); + showToast(t(data.message), "success"); }, onError: (err) => { showToast(err.message, "error"); diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index c4c7c476d7..6ed383cb2e 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -178,7 +178,7 @@ export default function Success(props: PageProps) { }, }); - const noShowMutation = trpc.viewer.public.noShow.useMutation({ + const hostNoShowMutation = trpc.viewer.public.markHostAsNoShow.useMutation({ onSuccess: async () => { showToast("Thank you, feedback submitted", "success"); }, @@ -189,7 +189,7 @@ export default function Success(props: PageProps) { useEffect(() => { if (noShow) { - noShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true }); + hostNoShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/apps/web/modules/test-setup.ts b/apps/web/modules/test-setup.ts index 51ee523554..df683202a6 100644 --- a/apps/web/modules/test-setup.ts +++ b/apps/web/modules/test-setup.ts @@ -140,7 +140,7 @@ vi.mock("@calcom/trpc/react", () => ({ submitRating: { useMutation: vi.fn(), }, - noShow: { + markHostAsNoShow: { useMutation: vi.fn(), }, }, diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 6f74353040..b1e0dc76a8 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -1,32 +1,38 @@ +import { type TFunction } from "i18next"; + import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; +import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/client"; -import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/publicViewer/noShow.schema"; +import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; + +const buildResultPayload = async ( + bookingUid: string, + attendeeEmails: string[], + inputAttendees: NonNullable, + t: TFunction +) => { + const attendees = await updateAttendees(bookingUid, attendeeEmails, inputAttendees); -const getResultPayload = async (attendees: { email: string; noShow: boolean }[]) => { if (attendees.length === 1) { const [attendee] = attendees; return { - messageKey: attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", + message: t(attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", { + x: attendee.email ?? "User", + }), attendees: [attendee], }; } return { - messageKey: "no_show_updated", + message: t("no_show_updated"), attendees: attendees, }; }; -type ResponsePayload = { - attendees: { email: string; noShow: boolean }[]; - noShowHost: boolean; - message: string; - messageKey: string | undefined; -}; - const logFailedResults = (results: PromiseSettledResult[]) => { const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[]; if (failed.length < 1) return; @@ -34,99 +40,68 @@ const logFailedResults = (results: PromiseSettledResult[]) => { console.error("Failed to update no-show status", failedMessage.join(",")); }; -const handleMarkNoShow = async ({ bookingUid, attendees, noShowHost }: TNoShowInputSchema) => { - const responsePayload: ResponsePayload = { - attendees: [], - noShowHost: false, - message: "Failed to update no-show status", - messageKey: undefined, - }; +class ResponsePayload { + attendees: { email: string; noShow: boolean }[]; + noShowHost: boolean; + message: string; + + constructor() { + this.attendees = []; + this.noShowHost = false; + this.message = ""; + } + + setAttendees(attendees: { email: string; noShow: boolean }[]) { + this.attendees = attendees; + } + + setNoShowHost(noShowHost: boolean) { + this.noShowHost = noShowHost; + } + + setMessage(message: string) { + this.message = message; + } + + getPayload() { + return { + attendees: this.attendees, + noShowHost: this.noShowHost, + message: this.message, + }; + } +} + +const handleMarkNoShow = async ({ + bookingUid, + attendees, + noShowHost, + userId, + locale, +}: TNoShowInputSchema & { userId?: number; locale?: string }) => { + const responsePayload = new ResponsePayload(); + const t = await getTranslation(locale ?? "en", "common"); + try { const attendeeEmails = attendees?.map((attendee) => attendee.email) || []; + if (attendees && attendeeEmails.length > 0) { - const allAttendees = await prisma.attendee.findMany({ - where: { - AND: [ - { - booking: { - uid: bookingUid, - }, - email: { - in: attendeeEmails, - }, - }, - ], - }, - select: { - id: true, - email: true, - }, - }); + await assertCanAccessBooking(bookingUid, userId); - const allAttendeesMap = allAttendees.reduce((acc, attendee) => { - acc[attendee.email] = attendee; - return acc; - }, {} as Record); + const payload = await buildResultPayload(bookingUid, attendeeEmails, attendees, t); - const updatePromises = attendees.map((attendee) => { - const attendeeToUpdate = allAttendeesMap[attendee.email]; - if (!attendeeToUpdate) return; - return prisma.attendee.update({ - where: { id: attendeeToUpdate.id }, - data: { noShow: attendee.noShow }, - }); - }); - - 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: { - id: true, - 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"); - const message = t(payload.messageKey, { x: payload.attendees[0]?.email || "User" }); + const { webhooks, bookingId } = await getWebhooksService(bookingUid); 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, bookingUid, - bookingId: booking?.id, + bookingId, }); - responsePayload["attendees"] = payload.attendees; - responsePayload["message"] = message; - responsePayload["messageKey"] = payload.messageKey; + responsePayload.setAttendees(payload.attendees); + responsePayload.setMessage(payload.message); } if (noShowHost) { @@ -139,17 +114,103 @@ const handleMarkNoShow = async ({ bookingUid, attendees, noShowHost }: TNoShowIn }, }); - responsePayload["noShowHost"] = true; - responsePayload["message"] = "No-show status updated"; + responsePayload.setNoShowHost(true); + responsePayload.setMessage(t("booking_no_show_updated")); } - return responsePayload; + return responsePayload.getPayload(); } catch (error) { if (error instanceof Error) { logger.error(error.message); } - return { message: "Failed to update no-show status" }; + throw new HttpError({ statusCode: 500, message: "Failed to update no-show status" }); } }; +const updateAttendees = async ( + bookingUid: string, + attendeeEmails: string[], + attendees: NonNullable +) => { + const allAttendees = await prisma.attendee.findMany({ + where: { + AND: [ + { + booking: { + uid: bookingUid, + }, + email: { + in: attendeeEmails, + }, + }, + ], + }, + select: { + id: true, + email: true, + }, + }); + + const allAttendeesMap = allAttendees.reduce((acc, attendee) => { + acc[attendee.email] = attendee; + return acc; + }, {} as Record); + + const updatePromises = attendees.map((attendee) => { + const attendeeToUpdate = allAttendeesMap[attendee.email]; + if (!attendeeToUpdate) return; + return prisma.attendee.update({ + where: { id: attendeeToUpdate.id }, + data: { noShow: attendee.noShow }, + }); + }); + + const results = await Promise.allSettled(updatePromises); + logFailedResults(results); + + return 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 getWebhooksService = async (bookingUid: string) => { + const booking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + id: true, + 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, + }); + + return { webhooks, bookingId: booking?.id }; +}; + +const assertCanAccessBooking = async (bookingUid: string, userId?: number) => { + if (!userId) throw new HttpError({ statusCode: 401 }); + + const booking = await BookingRepository.findBookingByUidAndUserId({ bookingUid, userId }); + + if (!booking) + throw new HttpError({ statusCode: 403, message: "You are not allowed to access this booking" }); +}; + export default handleMarkNoShow; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index c881d23dfb..b58d75a86b 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -53,4 +53,48 @@ export class BookingRepository { }, }); } + + static async findBookingByUidAndUserId({ bookingUid, userId }: { bookingUid: string; userId: number }) { + return await prisma.booking.findFirst({ + where: { + uid: bookingUid, + OR: [ + { userId: userId }, + { + eventType: { + hosts: { + some: { + userId, + }, + }, + }, + }, + { + eventType: { + users: { + some: { + id: userId, + }, + }, + }, + }, + { + eventType: { + team: { + members: { + some: { + userId, + accepted: true, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + ], + }, + }); + } } diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index be9c80e721..db3818e462 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -12,6 +12,7 @@ import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schem import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema"; import { ZIntegrationsInputSchema } from "./integrations.schema"; import { ZLocationOptionsInputSchema } from "./locationOptions.schema"; +import { ZNoShowInputSchema } from "./markNoShow.schema"; import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema"; import { me } from "./procedures/me"; import { teamsAndUserProfilesQuery } from "./procedures/teamsAndUserProfilesQuery"; @@ -58,6 +59,7 @@ type AppsRouterHandlerCache = { addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler; getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler; outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList; + markNoShow?: typeof import("./markNoShow.handler").markNoShow; }; const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; @@ -497,4 +499,16 @@ export const loggedInViewerRouter = router({ return UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList(); }), + markNoShow: authedProcedure.input(ZNoShowInputSchema).mutation(async (opts) => { + if (!UNSTABLE_HANDLER_CACHE.markNoShow) { + UNSTABLE_HANDLER_CACHE.markNoShow = (await import("./markNoShow.handler")).markNoShow; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.markNoShow) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.markNoShow(opts); + }), }); diff --git a/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts new file mode 100644 index 0000000000..2dced8a1f5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts @@ -0,0 +1,23 @@ +import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TNoShowInputSchema } from "./markNoShow.schema"; + +type NoShowOptions = { + input: TNoShowInputSchema; + ctx: { + user: NonNullable; + }; +}; + +export const markNoShow = async ({ ctx, input }: NoShowOptions) => { + const { bookingUid, attendees, noShowHost } = input; + + return handleMarkNoShow({ + bookingUid, + attendees, + noShowHost, + userId: ctx.user.id, + locale: ctx.user.locale, + }); +}; diff --git a/packages/trpc/server/routers/publicViewer/noShow.schema.ts b/packages/trpc/server/routers/loggedInViewer/markNoShow.schema.ts similarity index 100% rename from packages/trpc/server/routers/publicViewer/noShow.schema.ts rename to packages/trpc/server/routers/loggedInViewer/markNoShow.schema.ts diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx index 501663e47c..c52aca102a 100644 --- a/packages/trpc/server/routers/publicViewer/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -3,7 +3,7 @@ import { importHandler, router } from "../../trpc"; import { slotsRouter } from "../viewer/slots/_router"; import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerificationRequired.schema"; import { i18nInputSchema } from "./i18n.schema"; -import { ZNoShowInputSchema } from "./noShow.schema"; +import { ZMarkHostAsNoShowInputSchema } from "./markHostAsNoShow.schema"; import { event } from "./procedures/event"; import { session } from "./procedures/session"; import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; @@ -29,8 +29,11 @@ export const publicViewerRouter = router({ const handler = await importHandler(namespaced("submitRating"), () => import("./submitRating.handler")); return handler(opts); }), - noShow: publicProcedure.input(ZNoShowInputSchema).mutation(async (opts) => { - const handler = await importHandler(namespaced("noShow"), () => import("./noShow.handler")); + markHostAsNoShow: publicProcedure.input(ZMarkHostAsNoShowInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("markHostAsNoShow"), + () => import("./markHostAsNoShow.handler") + ); return handler(opts); }), samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async (opts) => { diff --git a/packages/trpc/server/routers/publicViewer/noShow.handler.ts b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts similarity index 55% rename from packages/trpc/server/routers/publicViewer/noShow.handler.ts rename to packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts index 015ec3f9fb..f6da15cbee 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts @@ -1,15 +1,15 @@ import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; -import type { TNoShowInputSchema } from "./noShow.schema"; +import type { TNoShowInputSchema } from "./markHostAsNoShow.schema"; type NoShowOptions = { input: TNoShowInputSchema; }; export const noShowHandler = async ({ input }: NoShowOptions) => { - const { bookingUid, attendees, noShowHost } = input; + const { bookingUid, noShowHost } = input; - return handleMarkNoShow({ bookingUid, attendees, noShowHost }); + return handleMarkNoShow({ bookingUid, noShowHost }); }; export default noShowHandler; diff --git a/packages/trpc/server/routers/publicViewer/markHostAsNoShow.schema.ts b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.schema.ts new file mode 100644 index 0000000000..053441cf8a --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZMarkHostAsNoShowInputSchema = z.object({ + bookingUid: z.string(), + noShowHost: z.boolean(), +}); + +export type TNoShowInputSchema = z.infer;