diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index d7e6a1b4eb..c38d047ffa 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -26,10 +26,11 @@ "dependencies": { "@calcom/platform-constants": "*", "@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13", + "@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14", "@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15", + "@calcom/platform-libraries-0.0.17": "npm:@calcom/platform-libraries@0.0.17", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4", - "@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", 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 3831d8fc72..2849a2f425 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -1,7 +1,9 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input"; +import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input"; import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; +import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; @@ -32,7 +34,7 @@ import { Request } from "express"; import { NextApiRequest } from "next/types"; import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; -import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; import { getAllUserBookings, getBookingInfo, @@ -46,6 +48,7 @@ import { handleNewRecurringBooking, handleInstantMeeting, } from "@calcom/platform-libraries-0.0.2"; +import { handleMarkNoShow } from "@calcom/platform-libraries-0.0.17"; import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -194,6 +197,27 @@ export class BookingsController { throw new InternalServerErrorException("Could not cancel booking."); } + @Post("/:bookingUid/mark-no-show") + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard) + async markNoShow( + @Body() body: MarkNoShowInput, + @Param("bookingUid") bookingUid: string + ): Promise { + try { + const markNoShowResponse = await handleMarkNoShow({ + bookingUid: bookingUid, + attendees: body.attendees, + noShowHost: body.noShowHost, + }); + + return { status: SUCCESS_STATUS, data: markNoShowResponse }; + } catch (err) { + this.handleBookingErrors(err, "no-show"); + } + throw new InternalServerErrorException("Could not mark no show."); + } + @Post("/recurring") async createRecurringBooking( @Req() req: BookingRequest, @@ -287,8 +311,14 @@ export class BookingsController { return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; } - private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void { - const errMsg = `Error while creating ${type ? type + " " : ""}booking.`; + private handleBookingErrors( + err: Error | HttpError | unknown, + type?: "recurring" | `instant` | "no-show" + ): void { + const errMsg = + type === "no-show" + ? `Error while marking no-show.` + : `Error while creating ${type ? type + " " : ""}booking.`; if (err instanceof HttpError) { const httpError = err as HttpError; throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); diff --git a/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts b/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts new file mode 100644 index 0000000000..0630f08fcc --- /dev/null +++ b/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts @@ -0,0 +1,22 @@ +import { Type } from "class-transformer"; +import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator"; + +class Attendee { + @IsEmail() + email!: string; + + @IsBoolean() + noShow!: boolean; +} + +export class MarkNoShowInput { + @IsBoolean() + @IsOptional() + noShowHost?: boolean; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @IsOptional() + attendees?: Attendee[]; +} 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 new file mode 100644 index 0000000000..5b1e5de68d --- /dev/null +++ b/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Attendee { + @IsEmail() + email!: string; + + @IsBoolean() + noShow!: boolean; +} + +class HandleMarkNoShowData { + @IsString() + message!: string; + + @IsBoolean() + @IsOptional() + noShowHost?: boolean; + + @IsString() + @IsOptional() + messageKey?: string; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @IsOptional() + attendees?: Attendee[]; +} + +export class MarkNoShowOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: HandleMarkNoShowData, + }) + @ValidateNested() + @Type(() => HandleMarkNoShowData) + data!: HandleMarkNoShowData; +} diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 1cb6b2440b..ae0d19f9f2 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -692,7 +692,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { const noShowMutation = trpc.viewer.public.noShow.useMutation({ onSuccess: async (data) => { - showToast(t(data.message, { x: name || email }), "success"); + showToast( + t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }), + "success" + ); }, onError: (err) => { showToast(err.message, "error"); @@ -800,7 +803,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { const { t } = useLocale(); const noShowMutation = trpc.viewer.public.noShow.useMutation({ onSuccess: async (data) => { - showToast(t(data.message), "success"); + showToast(t("messageKey" in data && data.messageKey ? data.messageKey : 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 7cb09d7929..e63ad3bb9a 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -186,7 +186,7 @@ export default function Success(props: PageProps) { useEffect(() => { if (noShow) { - noShowMutation.mutate({ bookingUid: bookingInfo.uid }); + noShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 09b85c3812..92163d2937 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -85,3 +85,7 @@ export type BookingResponse = Awaited< export type InstantBookingResponse = Awaited< ReturnType >; + +export type MarkNoShowResponse = Awaited< + ReturnType +>; diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts new file mode 100644 index 0000000000..6f74353040 --- /dev/null +++ b/packages/features/handleMarkNoShow.ts @@ -0,0 +1,155 @@ +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 "@calcom/trpc/server/routers/publicViewer/noShow.schema"; + +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", + attendees: [attendee], + }; + } + return { + messageKey: "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; + const failedMessage = failed.map((r) => r.reason); + 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, + }; + 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, + }, + }); + + 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); + + 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" }); + + 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, + }); + + responsePayload["attendees"] = payload.attendees; + responsePayload["message"] = message; + responsePayload["messageKey"] = payload.messageKey; + } + + if (noShowHost) { + await prisma.booking.update({ + where: { + uid: bookingUid, + }, + data: { + noShowHost: true, + }, + }); + + responsePayload["noShowHost"] = true; + responsePayload["message"] = "No-show status updated"; + } + + return responsePayload; + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + return { message: "Failed to update no-show status" }; + } +}; + +export default handleMarkNoShow; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 54a7d84437..1a6a41140b 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -4,6 +4,7 @@ import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking"; import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; +import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting"; import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings"; import { symmetricEncrypt } from "@calcom/lib/crypto"; @@ -41,6 +42,8 @@ export { handleNewBooking }; const handleInstantMeeting = instantMeetingMethods.default; export { handleInstantMeeting }; +export { handleMarkNoShow }; + export { getAvailableSlots }; export type AvailableSlotsType = Awaited>; export { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking"; diff --git a/packages/trpc/server/routers/publicViewer/noShow.handler.ts b/packages/trpc/server/routers/publicViewer/noShow.handler.ts index 0a0cb23946..9c751d1e8e 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/noShow.handler.ts @@ -1,9 +1,4 @@ -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 handleMarkNoShow from "@calcom/features/handleMarkNoShow"; import type { TNoShowInputSchema } from "./noShow.schema"; @@ -11,118 +6,10 @@ 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; - 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, - }, - }); - 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); - 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"); - 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, - bookingId: booking?.id, - }); - 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" }; - } + return handleMarkNoShow({ bookingUid, attendees }); }; export default noShowHandler; diff --git a/packages/trpc/server/routers/publicViewer/noShow.schema.ts b/packages/trpc/server/routers/publicViewer/noShow.schema.ts index ff1b16374a..72f98cc559 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.schema.ts +++ b/packages/trpc/server/routers/publicViewer/noShow.schema.ts @@ -1,15 +1,26 @@ import { z } from "zod"; -export const ZNoShowInputSchema = z.object({ - bookingUid: z.string(), - attendees: z - .array( - z.object({ - email: z.string(), - noShow: z.boolean(), - }) - ) - .optional(), -}); +export const ZNoShowInputSchema = z + .object({ + bookingUid: z.string(), + attendees: z + .array( + z.object({ + email: z.string(), + noShow: z.boolean(), + }) + ) + .optional(), + noShowHost: z.boolean().optional(), + }) + .refine( + (data) => { + return (data.attendees && data.attendees.length > 0) || data.noShowHost !== undefined; + }, + { + message: "At least one of 'attendees' or 'noShowHost' must be provided", + path: ["attendees", "noShowHost"], + } + ); export type TNoShowInputSchema = z.infer;