refactor: Use confirmHandler directly in link and verify-booking-token routes (#26567)

Refactors the link and verify-booking-token API routes to call confirmHandler directly instead of using the tRPC caller pattern. This simplifies the code by removing the need for session getter, legacy request building, and context creation.

Changes:
- apps/web/app/api/link/route.ts: Use confirmHandler directly with ctx and input
- apps/web/app/api/verify-booking-token/route.ts: Use confirmHandler directly with ctx and input
- Updated tests to mock confirmHandler instead of tRPC caller

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom BalharaandDevin AI authored and GitHub committed 2026-01-08 08:23:48 +00:00
1 parent f5d345b133
commit 2f92563606
4 files changed
+228 -179

No files matched your search

+41 -40
View File
@@ -1,5 +1,9 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import type { Mock } from "vitest";
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
@@ -36,44 +40,47 @@ vi.mock("@calcom/lib/crypto", () => ({
),
}));
vi.mock("@calcom/prisma", () => ({
default: {
vi.mock("@calcom/prisma", () => {
const mockBookingFindUniqueOrThrow = vi.fn().mockResolvedValue({
id: 1,
uid: "test-booking-uid",
recurringEventId: null,
});
const mockUserFindUniqueOrThrow = vi.fn().mockResolvedValue({
id: 1,
uuid: "user-uuid",
email: "test@example.com",
username: "testuser",
role: "USER",
destinationCalendar: null,
});
const mockPrismaObj = {
booking: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
uid: "test-booking-uid",
recurringEventId: null,
}),
findUniqueOrThrow: mockBookingFindUniqueOrThrow,
},
user: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
locale: "en",
}),
findUniqueOrThrow: mockUserFindUniqueOrThrow,
},
};
return {
default: mockPrismaObj,
prisma: mockPrismaObj,
};
});
vi.mock("@calcom/trpc/server/routers/viewer/bookings/confirm.handler", () => ({
confirmHandler: vi.fn(),
}));
vi.mock("@calcom/lib/tracing/factory", () => ({
distributedTracing: {
createTrace: vi.fn().mockReturnValue({}),
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
// Import after mocks are set up
import { GET } from "../route";
import prisma from "@calcom/prisma";
const createMockRequest = (url: string): NextRequest => {
const urlObj = new URL(url);
@@ -160,14 +167,11 @@ describe("link route", () => {
});
describe("GET handler - error handling", () => {
it("should redirect with error message when TRPC throws an error", async () => {
it("should redirect with error message when confirmHandler throws a TRPCError", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })),
}));
// Mock confirmHandler to throw a TRPCError
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" }));
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
@@ -186,11 +190,8 @@ describe("link route", () => {
it("should preserve origin in error redirect URL", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })),
}));
// Mock confirmHandler to throw a TRPCError
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" }));
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
+40 -64
View File
@@ -1,19 +1,12 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import { TRPCError } from "@trpc/server";
enum DirectAction {
@@ -36,29 +29,6 @@ const decryptedSchema = z.object({
platformBookingUrl: z.string().optional(),
});
// Move the sessionGetter function outside the GET function
const createSessionGetter = (userId: number, userUuid: string) => async () => {
return {
user: {
id: userId,
uuid: userUuid,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
};
async function handler(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
@@ -83,45 +53,51 @@ async function handler(request: NextRequest) {
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
id: true,
uuid: true,
email: true,
username: true,
role: true,
destinationCalendar: true,
},
});
// Use the factory function instead of declaring inside the block
const sessionGetter = createSessionGetter(userId, user.uuid);
try {
/** @see https://trpc.io/docs/server-side-calls */
// Create a legacy request object for compatibility
const legacyReq = buildLegacyRequest(await headers(), await cookies());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response is mocked as it's not used in this context
const res = {} as any;
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
const createCaller = createCallerFactory(bookingsRouter);
const caller = createCaller({
...ctx,
req: legacyReq,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
platformClientParams: platformClientId
? {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
}
: undefined,
await confirmHandler({
ctx: {
user: {
id: user.id,
uuid: user.uuid,
email: user.email,
username: user.username ?? "",
role: user.role,
destinationCalendar: user.destinationCalendar ?? null,
},
traceContext: distributedTracing.createTrace("confirm_booking_magic_link"),
},
input: {
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
emailsEnabled: true,
platformClientParams: platformClientId
? {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
}
: undefined,
},
});
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url));
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)
);
}
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
@@ -1,5 +1,8 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import type { Mock } from "vitest";
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
@@ -8,10 +11,6 @@ vi.mock("app/api/defaultResponderForAppDir", () => ({
handler(req),
}));
vi.mock("app/api/parseRequestData", () => ({
parseRequestData: vi.fn().mockResolvedValue({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
@@ -34,50 +33,145 @@ vi.mock("next/server", () => ({
vi.mock("@calcom/prisma", () => ({
default: {
booking: {
findUnique: vi.fn(),
update: vi.fn(),
findUnique: vi.fn().mockImplementation(({ where }: { where: { oneTimePassword: string } }) => {
const booking = Object.values(DB.bookings).find(
(booking) => booking.oneTimePassword === where.oneTimePassword
);
return Promise.resolve(booking ?? null);
}),
update: vi
.fn()
.mockImplementation(({ where }: { where: { id: number }; data: { oneTimePassword: null } }) => {
const booking = DB.bookings[where.id];
return Promise.resolve(booking ?? null);
}),
},
user: {
findUniqueOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn().mockImplementation(({ where }: { where: { id: number } }) => {
const user = Object.values(DB.users).find((user) => user.id === where.id);
return Promise.resolve(user ?? null);
}),
},
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
vi.mock("@calcom/trpc/server/routers/viewer/bookings/confirm.handler", () => ({
confirmHandler: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
vi.mock("@calcom/lib/tracing/factory", () => ({
distributedTracing: {
createTrace: vi.fn().mockReturnValue({}),
},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
// Store for mock request body - will be set by tests
let mockRequestBody: Record<string, unknown> = {};
vi.mock("app/api/parseRequestData", () => ({
parseRequestData: vi.fn().mockImplementation(() => Promise.resolve(mockRequestBody)),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
function setMockRequestBody(body: Record<string, unknown>) {
mockRequestBody = body;
}
function expectErrorRedirect(res: Response, path: string, error: string) {
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe(path);
expect(redirectUrl.searchParams.get("error")).toBe(error);
}
// Import after mocks are set up
import { GET, POST } from "../route";
const DB = {
bookings: {} as Record<
string,
{
id: number;
uid: string;
oneTimePassword: string;
recurringEventId?: string | null;
}
>,
users: {} as Record<
string,
{
id: number;
uuid: string;
email: string;
username: string;
role: string;
destinationCalendar: unknown | null;
}
>,
};
function createMockBooking(overrides: {
id: number;
uid: string;
oneTimePassword: string;
recurringEventId?: string | null;
}) {
DB.bookings[overrides.uid] = {
id: overrides.id,
uid: overrides.uid,
oneTimePassword: overrides.oneTimePassword,
recurringEventId: overrides.recurringEventId ?? null,
};
}
const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => {
function createMockUser(overrides: {
id: number;
uuid: string;
email: string;
username: string;
role: string;
destinationCalendar: unknown | null;
}) {
DB.users[overrides.uuid] = {
id: overrides.id,
uuid: overrides.uuid,
email: overrides.email,
username: overrides.username ?? null,
role: overrides.role ?? "USER",
destinationCalendar: overrides.destinationCalendar ?? null,
};
}
const createMockRequest = (
url: string,
method: "GET" | "POST" = "GET",
body?: Record<string, unknown>
): NextRequest => {
const urlObj = new URL(url);
const headers = new Headers();
headers.set("content-type", "application/json");
return {
method,
url,
nextUrl: {
searchParams: urlObj.searchParams,
},
headers,
json:
method === "POST"
? async () => {
process.stdout.write("json fn called: " + JSON.stringify(body));
return Promise.resolve(body ?? {});
}
: undefined,
} as unknown as NextRequest;
};
describe("verify-booking-token route", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock body and DB
setMockRequestBody({});
Object.keys(DB.bookings).forEach((key) => delete DB.bookings[key]);
Object.keys(DB.users).forEach((key) => delete DB.users[key]);
});
describe("GET handler", () => {
+34 -56
View File
@@ -1,19 +1,12 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import { headers, cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import { TRPCError } from "@trpc/server";
enum DirectAction {
@@ -37,7 +30,10 @@ async function getHandler(request: NextRequest) {
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
new URL(
`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`,
request.url
)
);
}
@@ -89,55 +85,37 @@ async function handleBookingAction(
const user = await prisma.user.findUniqueOrThrow({
where: { id: Number(userId) },
select: {
id: true,
uuid: true,
email: true,
username: true,
role: true,
destinationCalendar: true,
},
});
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: Number(userId),
uuid: user.uuid,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
try {
/** @see https://trpc.io/docs/server-side-calls */
const createCaller = createCallerFactory(bookingsRouter);
// Use buildLegacyRequest to create a request object compatible with Pages Router
const legacyReq = request ? buildLegacyRequest(await headers(), await cookies()) : ({} as any);
const res = {} as any;
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
const caller = createCaller({
...ctx,
req: legacyReq,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
/** Ignored reason input unless we're rejecting */
reason: action === DirectAction.REJECT ? reason : undefined,
await confirmHandler({
ctx: {
user: {
id: user.id,
uuid: user.uuid,
email: user.email,
username: user.username ?? "",
role: user.role,
destinationCalendar: user.destinationCalendar ?? null,
},
traceContext: distributedTracing.createTrace("confirm_booking_verify_token"),
},
input: {
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
/** Ignored reason input unless we're rejecting */
reason: action === DirectAction.REJECT ? reason : undefined,
emailsEnabled: true,
},
});
} catch (e) {
let message = "Error confirming booking";