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:
@@ -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
|
||||
}, []);
|
||||
|
||||
@@ -140,7 +140,7 @@ vi.mock("@calcom/trpc/react", () => ({
|
||||
submitRating: {
|
||||
useMutation: vi.fn(),
|
||||
},
|
||||
noShow: {
|
||||
markHostAsNoShow: {
|
||||
useMutation: vi.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+3
-3
@@ -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>;
|
||||
Reference in New Issue
Block a user