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
This commit is contained in:
Udit Takkar
2024-08-07 15:16:31 +05:30
committed by GitHub
parent 3ab735ba9c
commit e075dcdeaa
13 changed files with 263 additions and 115 deletions
@@ -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<MarkNoShowOutput> {
@@ -219,6 +220,7 @@ export class BookingsController {
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
userId,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
@@ -20,10 +20,6 @@ class HandleMarkNoShowData {
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@@ -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");
@@ -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
}, []);
+1 -1
View File
@@ -140,7 +140,7 @@ vi.mock("@calcom/trpc/react", () => ({
submitRating: {
useMutation: vi.fn(),
},
noShow: {
markHostAsNoShow: {
useMutation: vi.fn(),
},
},
+156 -95
View File
@@ -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<TNoShowInputSchema["attendees"]>,
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<any>[]) => {
const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[];
if (failed.length < 1) return;
@@ -34,99 +40,68 @@ const logFailedResults = (results: PromiseSettledResult<any>[]) => {
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<string, { id: number; email: string }>);
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<TNoShowInputSchema["attendees"]>
) => {
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<string, { id: number; email: string }>);
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;
+44
View File
@@ -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"],
},
},
},
},
},
},
],
},
});
}
}
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
};
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,
});
};
@@ -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) => {
@@ -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;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZMarkHostAsNoShowInputSchema = z.object({
bookingUid: z.string(),
noShowHost: z.boolean(),
});
export type TNoShowInputSchema = z.infer<typeof ZMarkHostAsNoShowInputSchema>;