diff --git a/apps/web/pages/api/cron/bookingReminder.ts b/apps/web/app/api/cron/bookingReminder/route.ts similarity index 91% rename from apps/web/pages/api/cron/bookingReminder.ts rename to apps/web/app/api/cron/bookingReminder/route.ts index 149f372c88..85e6b59e2f 100644 --- a/apps/web/pages/api/cron/bookingReminder.ts +++ b/apps/web/app/api/cron/bookingReminder/route.ts @@ -1,4 +1,5 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import dayjs from "@calcom/dayjs"; import { sendOrganizerRequestReminderEmail } from "@calcom/emails"; @@ -10,19 +11,16 @@ import { BookingStatus, ReminderType } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + if (process.env.CRON_API_KEY !== apiKey) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - if (req.method !== "POST") { - res.status(405).json({ message: "Invalid method" }); - return; + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60]; let notificationsSent = 0; + for (const interval of reminderIntervalMinutes) { const bookings = await prisma.booking.findMany({ where: { @@ -144,5 +142,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) notificationsSent++; } } - res.status(200).json({ notificationsSent }); + + return NextResponse.json({ notificationsSent }); } diff --git a/apps/web/app/api/cron/calendar-cache-cleanup/route.ts b/apps/web/app/api/cron/calendar-cache-cleanup/route.ts new file mode 100644 index 0000000000..f89da5ed33 --- /dev/null +++ b/apps/web/app/api/cron/calendar-cache-cleanup/route.ts @@ -0,0 +1,23 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import prisma from "@calcom/prisma"; + +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + + if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + } + + const deleted = await prisma.calendarCache.deleteMany({ + where: { + // Delete all cache entries that expired before now + expiresAt: { + lte: new Date(Date.now()), + }, + }, + }); + + return NextResponse.json({ ok: true, count: deleted.count }); +} diff --git a/apps/web/pages/api/cron/changeTimeZone.ts b/apps/web/app/api/cron/changeTimeZone/route.ts similarity index 90% rename from apps/web/pages/api/cron/changeTimeZone.ts rename to apps/web/app/api/cron/changeTimeZone/route.ts index 43cbbc4a0b..8618402795 100644 --- a/apps/web/pages/api/cron/changeTimeZone.ts +++ b/apps/web/app/api/cron/changeTimeZone/route.ts @@ -1,4 +1,5 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import dayjs from "@calcom/dayjs"; import prisma from "@calcom/prisma"; @@ -19,16 +20,11 @@ const travelScheduleSelect = { }, }; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; - if (process.env.CRON_API_KEY !== apiKey) { - res.status(401).json({ message: "Not authenticated" }); - return; - } +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - if (req.method !== "POST") { - res.status(405).json({ message: "Invalid method" }); - return; + if (process.env.CRON_API_KEY !== apiKey) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } let timeZonesChanged = 0; @@ -169,5 +165,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - res.status(200).json({ timeZonesChanged }); + return NextResponse.json({ timeZonesChanged }); } diff --git a/apps/web/pages/api/cron/downgradeUsers.ts b/apps/web/app/api/cron/downgradeUsers/route.ts similarity index 69% rename from apps/web/pages/api/cron/downgradeUsers.ts rename to apps/web/app/api/cron/downgradeUsers/route.ts index d4d4c15ee7..5b8b5348d6 100644 --- a/apps/web/pages/api/cron/downgradeUsers.ts +++ b/apps/web/app/api/cron/downgradeUsers/route.ts @@ -1,4 +1,5 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { z } from "zod"; import { TeamBilling } from "@calcom/ee/billing/teams"; @@ -8,20 +9,17 @@ const querySchema = z.object({ page: z.coerce.number().min(0).optional().default(0), }); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + if (process.env.CRON_API_KEY !== apiKey) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - if (req.method !== "POST") { - res.status(405).json({ message: "Invalid method" }); - return; + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time - let { page: pageNumber } = querySchema.parse(req.query); + + let { page: pageNumber } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); while (true) { const teams = await prisma.team.findMany({ @@ -51,5 +49,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) pageNumber++; } - res.json({ ok: true }); + return NextResponse.json({ ok: true }); } diff --git a/apps/web/pages/api/cron/monthlyDigestEmail.ts b/apps/web/app/api/cron/monthlyDigestEmail/route.ts similarity index 94% rename from apps/web/pages/api/cron/monthlyDigestEmail.ts rename to apps/web/app/api/cron/monthlyDigestEmail/route.ts index fb89f921f7..575c983ba9 100644 --- a/apps/web/pages/api/cron/monthlyDigestEmail.ts +++ b/apps/web/app/api/cron/monthlyDigestEmail/route.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; @@ -12,22 +13,17 @@ const querySchema = z.object({ page: z.coerce.number().min(0).optional().default(0), }); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); if (process.env.CRON_API_KEY !== apiKey) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - if (req.method !== "POST") { - res.status(405).json({ message: "Invalid method" }); - return; + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time - let { page: pageNumber } = querySchema.parse(req.query); + + let { page: pageNumber } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); const firstDateOfMonth = new Date(); firstDateOfMonth.setDate(1); @@ -314,5 +310,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) pageNumber++; } - res.json({ ok: true }); + + return NextResponse.json({ ok: true }); } diff --git a/apps/web/pages/api/cron/syncAppMeta.ts b/apps/web/app/api/cron/syncAppMeta/route.ts similarity index 81% rename from apps/web/pages/api/cron/syncAppMeta.ts rename to apps/web/app/api/cron/syncAppMeta/route.ts index 9b64919c39..407459544f 100644 --- a/apps/web/pages/api/cron/syncAppMeta.ts +++ b/apps/web/app/api/cron/syncAppMeta/route.ts @@ -1,4 +1,5 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import logger from "@calcom/lib/logger"; @@ -14,15 +15,11 @@ const log = logger.getSubLogger({ * syncAppMeta makes sure any app metadata that has been replicated into the database * remains synchronized with any changes made to the app config files. */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; +export async function POST(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + if (process.env.CRON_API_KEY !== apiKey) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - if (req.method !== "POST") { - res.status(405).json({ message: "Invalid method" }); - return; + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } log.info(`🧐 Checking DB apps are in-sync with app metadata`); @@ -63,5 +60,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - res.json({ ok: true }); + return NextResponse.json({ ok: true }); } diff --git a/apps/web/app/api/email/route.ts b/apps/web/app/api/email/route.ts new file mode 100644 index 0000000000..04f340a9f5 --- /dev/null +++ b/apps/web/app/api/email/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; + +import { renderEmail } from "@calcom/emails"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { getTranslation } from "@calcom/lib/server/i18n"; + +/** + * This API endpoint is used for development purposes to preview email templates + */ +export async function GET() { + // Only allow in development mode + if (IS_PRODUCTION) { + return new NextResponse("Only for development purposes", { + status: 403, + }); + } + + const t = await getTranslation("en", "common"); + + // Render the email template + const emailHtml = await renderEmail("MonthlyDigestEmail", { + language: t, + Created: 12, + Completed: 13, + Rescheduled: 14, + Cancelled: 16, + mostBookedEvents: [ + { + eventTypeId: 3, + eventTypeName: "Test1", + count: 3, + }, + { + eventTypeId: 4, + eventTypeName: "Test2", + count: 5, + }, + ], + membersWithMostBookings: [ + { + userId: 4, + user: { + id: 4, + name: "User1 name", + email: "email.com", + avatar: "none", + username: "User1", + }, + count: 4, + }, + { + userId: 6, + user: { + id: 6, + name: "User2 name", + email: "email2.com", + avatar: "none", + username: "User2", + }, + count: 8, + }, + ], + admin: { email: "admin.com", name: "admin" }, + team: { name: "Team1", id: 4 }, + }); + + // Create a response with the HTML content + const response = new NextResponse(emailHtml); + + // Set appropriate headers + response.headers.set("Content-Type", "text/html"); + response.headers.set("Cache-Control", "no-cache, no-store, private, must-revalidate"); + + return response; +} diff --git a/apps/web/app/api/future-opt-in/route.ts b/apps/web/app/api/future-opt-in/route.ts new file mode 100644 index 0000000000..fd3c3d132a --- /dev/null +++ b/apps/web/app/api/future-opt-in/route.ts @@ -0,0 +1,37 @@ +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { FUTURE_ROUTES_OVERRIDE_COOKIE_NAME as COOKIE_NAME } from "@calcom/lib/constants"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +export async function GET() { + const headersList = headers(); + const cookiesList = cookies(); + const legacyReq = buildLegacyRequest(headersList, cookiesList); + + const session = await getServerSession({ req: legacyReq }); + + if (!session || !session.user || !session.user.email) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + } + + let redirectUrl = "/"; + + // We take you back where you came from if possible + const referer = headersList.get("referer"); + if (referer) redirectUrl = referer; + + const response = NextResponse.redirect(redirectUrl); + + // If has the cookie, Opt-out of V2 + if (cookiesList.has(COOKIE_NAME) && cookiesList.get(COOKIE_NAME)?.value === "1") { + response.cookies.set(COOKIE_NAME, "0", { maxAge: 0, path: "/" }); + } else { + /* Opt-in to V2 */ + response.cookies.set(COOKIE_NAME, "1", { path: "/" }); + } + + return response; +} diff --git a/apps/web/pages/api/generate-referral-link.ts b/apps/web/app/api/generate-referral-link/route.ts similarity index 67% rename from apps/web/pages/api/generate-referral-link.ts rename to apps/web/app/api/generate-referral-link/route.ts index 79f049e912..dd0cb511fb 100644 --- a/apps/web/pages/api/generate-referral-link.ts +++ b/apps/web/app/api/generate-referral-link/route.ts @@ -1,25 +1,28 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; import { dub } from "@calcom/features/auth/lib/dub"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import prisma from "@calcom/prisma"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") { - return res.status(405).json({ message: "Method not allowed" }); - } +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - const session = await getServerSession({ req }); +export async function POST() { + const headersList = headers(); + const cookiesList = cookies(); + const legacyReq = buildLegacyRequest(headersList, cookiesList); + + const session = await getServerSession({ req: legacyReq }); if (!session?.user?.username) { - return res.status(401).json({ message: "Unauthorized" }); + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); } try { const { shortLink } = await dub.links.get({ externalId: `ext_${session.user.id.toString()}`, }); - return res.status(200).json({ shortLink }); + return NextResponse.json({ shortLink }); } catch (error) { console.log("Referral link not found, creating..."); } @@ -46,5 +49,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - return res.status(200).json({ shortLink }); + return NextResponse.json({ shortLink }); } diff --git a/apps/web/app/api/geolocation/route.ts b/apps/web/app/api/geolocation/route.ts new file mode 100644 index 0000000000..0fadae5149 --- /dev/null +++ b/apps/web/app/api/geolocation/route.ts @@ -0,0 +1,12 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const headersList = headers(); + const country = headersList.get("x-vercel-ip-country") || "Unknown"; + + const response = NextResponse.json({ country }); + response.headers.set("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"); + + return response; +} diff --git a/apps/web/app/api/intercom-hash/route.ts b/apps/web/app/api/intercom-hash/route.ts new file mode 100644 index 0000000000..9212c0afaf --- /dev/null +++ b/apps/web/app/api/intercom-hash/route.ts @@ -0,0 +1,30 @@ +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; +import crypto from "node:crypto"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +export async function GET() { + const headersList = headers(); + const cookiesList = cookies(); + const legacyReq = buildLegacyRequest(headersList, cookiesList); + + const session = await getServerSession({ req: legacyReq }); + const secret = process.env.INTERCOM_SECRET; + + if (!session) { + return NextResponse.json({ message: "user not authenticated" }, { status: 401 }); + } + + if (!secret) { + return NextResponse.json({ message: "Intercom Identity Verification secret not set" }, { status: 400 }); + } + + const hmac = crypto.createHmac("sha256", secret); + hmac.update(String(session.user.id)); + const hash = hmac.digest("hex"); + + return NextResponse.json({ hash }); +} diff --git a/apps/web/app/api/link/route.ts b/apps/web/app/api/link/route.ts new file mode 100644 index 0000000000..998ce6a617 --- /dev/null +++ b/apps/web/app/api/link/route.ts @@ -0,0 +1,112 @@ +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 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 type { UserProfile } from "@calcom/types/UserProfile"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { TRPCError } from "@trpc/server"; + +enum DirectAction { + ACCEPT = "accept", + REJECT = "reject", +} + +const querySchema = z.object({ + action: z.nativeEnum(DirectAction), + token: z.string(), + reason: z.string().optional(), +}); + +const decryptedSchema = z.object({ + bookingUid: z.string(), + userId: z.number().int(), +}); + +// Move the sessionGetter function outside the GET function +const createSessionGetter = (userId: number) => async () => { + return { + user: { + id: userId, + 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 */, + }; +}; + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const searchParams = request.nextUrl.searchParams; + + try { + const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries())); + + const decryptedData = JSON.parse( + symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "") + ); + + const { bookingUid, userId } = decryptedSchema.parse(decryptedData); + + const booking = await prisma.booking.findUniqueOrThrow({ + where: { uid: bookingUid }, + }); + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + }); + + // Use the factory function instead of declaring inside the block + const sessionGetter = createSessionGetter(userId); + + try { + /** @see https://trpc.io/docs/server-side-calls */ + // Create a legacy request object for compatibility + const legacyReq = buildLegacyRequest(headers(), cookies()); + const res = {} as any; // Response is still mocked as it's not used in this context + + const ctx = await createContext({ req: legacyReq, res }, sessionGetter); + const caller = bookingsRouter.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, + }); + } catch (e) { + let message = "Error confirming booking"; + if (e instanceof TRPCError) message = (e as TRPCError).message; + return NextResponse.redirect( + `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}` + ); + } + + return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`); + } catch (error) { + console.error("Error processing link request:", error); + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } +} diff --git a/apps/web/pages/api/logo.ts b/apps/web/app/api/logo/route.ts similarity index 74% rename from apps/web/pages/api/logo.ts rename to apps/web/app/api/logo/route.ts index 3c0f0a49a7..c7e966aeac 100644 --- a/apps/web/pages/api/logo.ts +++ b/apps/web/app/api/logo/route.ts @@ -1,4 +1,6 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { cookies, headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { z } from "zod"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; @@ -16,6 +18,8 @@ import { } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + const log = logger.getSubLogger({ prefix: ["[api/logo]"] }); function removePort(url: string) { @@ -123,10 +127,17 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) { where: { slug: subdomain, ...(isValidOrgDomain && { - metadata: { - path: ["isOrganization"], - equals: true, - }, + OR: [ + { + metadata: { + path: ["isOrganization"], + equals: true, + }, + }, + { + isOrganization: true, + }, + ], }), }, select: { @@ -151,15 +162,23 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) { /** * This API endpoint is used to serve the logo associated with a team if no logo is found we serve our default logo */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { query } = req; - const parsedQuery = logoApiSchema.parse(query); - const { isValidOrgDomain } = orgDomainConfig(req); +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const parsedQuery = logoApiSchema.parse(Object.fromEntries(searchParams.entries())); + + // Create a legacy request object for compatibility + const legacyReq = buildLegacyRequest(headers(), cookies()); + const { isValidOrgDomain } = orgDomainConfig(legacyReq); + + const hostname = request.headers.get("host"); + if (!hostname) { + return NextResponse.json({ error: "No hostname" }, { status: 400 }); + } - const hostname = req?.headers["host"]; - if (!hostname) throw new Error("No hostname"); const domains = extractSubdomainAndDomain(hostname); - if (!domains) throw new Error("No domains"); + if (!domains) { + return NextResponse.json({ error: "No domains" }, { status: 400 }); + } const [subdomain] = domains; const teamLogos = await getTeamLogos(subdomain, isValidOrgDomain); @@ -186,11 +205,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - res.setHeader("Content-Type", response.headers.get("content-type") as string); - res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60"); - res.send(buffer); + // Create a new response with the image buffer + const imageResponse = new NextResponse(buffer); + + // Set the appropriate headers + imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png"); + imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60"); + + return imageResponse; } catch (error) { - res.statusCode = 404; - res.json({ error: "Failed fetching logo" }); + return NextResponse.json({ error: "Failed fetching logo" }, { status: 404 }); } } diff --git a/apps/web/pages/api/me.ts b/apps/web/app/api/me/route.ts similarity index 53% rename from apps/web/pages/api/me.ts rename to apps/web/app/api/me/route.ts index cc83c44f36..e21f436331 100644 --- a/apps/web/pages/api/me.ts +++ b/apps/web/app/api/me/route.ts @@ -1,25 +1,36 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { performance } from "@calcom/lib/server/perfObserver"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + let isCold = true; -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { +export async function GET() { const prePrismaDate = performance.now(); const prisma = (await import("@calcom/prisma")).default; const preSessionDate = performance.now(); - const session = await getServerSession({ req }); - if (!session) return res.status(409).json({ message: "Unauthorized" }); + + // Create a legacy request object for compatibility + const legacyReq = buildLegacyRequest(headers(), cookies()); + + const session = await getServerSession({ req: legacyReq }); + if (!session) { + return NextResponse.json({ message: "Unauthorized" }, { status: 409 }); + } + const preUserDate = performance.now(); const user = await prisma.user.findUnique({ where: { id: session.user.id } }); - if (!user) return res.status(404).json({ message: "No user found" }); + if (!user) { + return NextResponse.json({ message: "No user found" }, { status: 404 }); + } + const lastUpdate = performance.now(); - res.setHeader("x-is-cold", isCold.toString()); - isCold = false; - - return res.status(200).json({ + // Create response with headers + const response = NextResponse.json({ message: `Hello ${user.name}`, prePrismaDate, prismaDuration: `Prisma took ${preSessionDate - prePrismaDate}ms`, @@ -30,4 +41,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) lastUpdate, wasCold: isCold, }); + + // Set custom header + response.headers.set("x-is-cold", isCold.toString()); + isCold = false; + + return response; } diff --git a/apps/web/app/api/nope/route.ts b/apps/web/app/api/nope/route.ts new file mode 100644 index 0000000000..8888b5c7ab --- /dev/null +++ b/apps/web/app/api/nope/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ message: "Please don't" }, { status: 400 }); +} + +export async function POST() { + return NextResponse.json({ message: "Please don't" }, { status: 400 }); +} diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/app/api/recorded-daily-video/route.ts similarity index 72% rename from apps/web/pages/api/recorded-daily-video.ts rename to apps/web/app/api/recorded-daily-video/route.ts index 0df77a2242..dfff81af43 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/app/api/recorded-daily-video/route.ts @@ -1,5 +1,7 @@ import { createHmac } from "crypto"; -import type { NextApiRequest, NextApiResponse } from "next"; +import { headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib"; import { sendDailyVideoRecordingEmails } from "@calcom/emails"; @@ -8,7 +10,6 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { getAllTranscriptsAccessLinkFromMeetingId, getDownloadLinkOfCalVideoByRecordingId, @@ -32,11 +33,7 @@ import { const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] }); -const computeSignature = ( - hmacSecret: string, - reqBody: NextApiRequest["body"], - webhookTimestampHeader: string | string[] | undefined -) => { +const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHeader: string | null) => { const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`; const base64DecodedSecret = Buffer.from(hmacSecret, "base64"); const hmac = createHmac("sha256", base64DecodedSecret); @@ -51,53 +48,53 @@ const getDownloadLinkOfCalVideo = async (recordingId: string) => { return downloadLink; }; -async function handler(req: NextApiRequest, res: NextApiResponse) { +export async function POST(request: NextRequest) { if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { - return res.status(405).json({ message: "No SendGrid API key or email" }); + return NextResponse.json({ message: "No SendGrid API key or email" }, { status: 405 }); } - if (testRequestSchema.safeParse(req.body).success) { - return res.status(200).json({ message: "Test request successful" }); + const body = await request.json(); + + if (testRequestSchema.safeParse(body).success) { + return NextResponse.json({ message: "Test request successful" }); } + const headersList = headers(); const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE; if (!testMode) { const hmacSecret = process.env.DAILY_WEBHOOK_SECRET; if (!hmacSecret) { - return res.status(405).json({ message: "No Daily Webhook Secret" }); + return NextResponse.json({ message: "No Daily Webhook Secret" }, { status: 405 }); } - const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]); + const webhookTimestamp = headersList.get("x-webhook-timestamp"); + const computed_signature = computeSignature(hmacSecret, body, webhookTimestamp); - if (req.headers["x-webhook-signature"] !== computed_signature) { - return res.status(403).json({ message: "Signature does not match" }); + if (headersList.get("x-webhook-signature") !== computed_signature) { + return NextResponse.json({ message: "Signature does not match" }, { status: 403 }); } } log.debug( "Daily video webhook Request Body:", safeStringify({ - body: req.body, + body, }) ); try { - if (req.body?.type === "recording.ready-to-download") { - const recordingReadyResponse = recordingReadySchema.safeParse(req.body); + if (body?.type === "recording.ready-to-download") { + const recordingReadyResponse = recordingReadySchema.safeParse(body); if (!recordingReadyResponse.success) { - return res.status(400).send({ - message: "Invalid Payload", - }); + return NextResponse.json({ message: "Invalid Payload" }, { status: 400 }); } const { room_name, recording_id, status } = recordingReadyResponse.data.payload; if (status !== "finished") { - return res.status(400).send({ - message: "Recording not finished", - }); + return NextResponse.json({ message: "Recording not finished" }, { status: 400 }); } const bookingReference = await getBookingReference(room_name); @@ -138,19 +135,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // Submit Transcription Batch Processor Job await submitBatchProcessorTranscriptionJob(recording_id); } catch (err) { - log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err)); + log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err)); } // send emails to all attendees only when user has team plan await sendDailyVideoRecordingEmails(evt, downloadLink); - return res.status(200).json({ message: "Success" }); - } else if (req.body.type === "meeting.ended") { - const meetingEndedResponse = meetingEndedSchema.safeParse(req.body); + return NextResponse.json({ message: "Success" }); + } else if (body.type === "meeting.ended") { + const meetingEndedResponse = meetingEndedSchema.safeParse(body); if (!meetingEndedResponse.success) { - return res.status(400).send({ - message: "Invalid Payload", - }); + return NextResponse.json({ message: "Invalid Payload" }, { status: 400 }); } const { room, meeting_id } = meetingEndedResponse.data.payload; @@ -159,7 +154,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const booking = await getBooking(bookingReference.bookingId as number); if (!booking.eventType?.canSendCalVideoTranscriptionEmails) { - return res.status(200).json({ + return NextResponse.json({ message: `Transcription emails are disabled for this event type ${booking.eventTypeId}`, }); } @@ -167,21 +162,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const transcripts = await getAllTranscriptsAccessLinkFromMeetingId(meeting_id); if (!transcripts || !transcripts.length) - return res - .status(200) - .json({ message: `No Transcripts found for room name ${room} and meeting id ${meeting_id}` }); + return NextResponse.json({ + message: `No Transcripts found for room name ${room} and meeting id ${meeting_id}`, + }); const evt = await getCalendarEvent(booking); await sendDailyVideoTranscriptEmails(evt, transcripts); - return res.status(200).json({ message: "Success" }); - } else if (req.body?.type === "batch-processor.job-finished") { - const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(req.body); + return NextResponse.json({ message: "Success" }); + } else if (body?.type === "batch-processor.job-finished") { + const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(body); if (!batchProcessorJobFinishedResponse.success) { - return res.status(400).send({ - message: "Invalid Payload", - }); + return NextResponse.json({ message: "Invalid Payload" }, { status: 400 }); } const { id, input } = batchProcessorJobFinishedResponse.data.payload; @@ -217,23 +210,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - return res.status(200).json({ message: "Success" }); + return NextResponse.json({ message: "Success" }); } else { - log.error("Invalid type in /recorded-daily-video", req.body); - - return res.status(200).json({ message: "Invalid type in /recorded-daily-video" }); + log.error("Invalid type in /recorded-daily-video", body); + return NextResponse.json({ message: "Invalid type in /recorded-daily-video" }); } } catch (err) { log.error("Error in /recorded-daily-video", err); if (err instanceof HttpError) { - return res.status(err.statusCode).json({ message: err.message }); + return NextResponse.json({ message: err.message }, { status: err.statusCode }); } else { - return res.status(500).json({ message: "something went wrong" }); + return NextResponse.json({ message: "something went wrong" }, { status: 500 }); } } } - -export default defaultHandler({ - POST: Promise.resolve({ default: handler }), -}); diff --git a/apps/web/app/api/scim/v2.0/[...directory]/route.ts b/apps/web/app/api/scim/v2.0/[...directory]/route.ts new file mode 100644 index 0000000000..122642a64e --- /dev/null +++ b/apps/web/app/api/scim/v2.0/[...directory]/route.ts @@ -0,0 +1,141 @@ +import type { DirectorySyncEvent, DirectorySyncRequest } from "@boxyhq/saml-jackson"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents"; +import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents"; +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { DIRECTORY_IDS_TO_LOG } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["[scim]"] }); + +// Extract the auth token from the request headers +const extractAuthToken = (req: NextRequest): string | null => { + const authHeader = req.headers.get("authorization") || null; + return authHeader ? authHeader.split(" ")[1] : null; +}; + +// Handle the SCIM events +const handleEvents = async (event: DirectorySyncEvent) => { + log.debug("handleEvents", safeStringify(event)); + const dSyncData = await prisma.dSyncData.findFirst({ + where: { + directoryId: event.directory_id, + }, + select: { + id: true, + organizationId: true, + }, + }); + + if (!dSyncData) { + throw new Error("Directory sync data not found"); + } + + const { organizationId } = dSyncData; + + if (!organizationId) { + throw new Error(`Org ID not found for dsync ${dSyncData.id}`); + } + + if (event.event.includes("group")) { + handleGroupEvents(event, organizationId); + } + + if (event.event === "user.created" || event.event === "user.updated") { + await handleUserEvents(event, organizationId); + } +}; + +// This is the handler for the SCIM API requests +export async function GET(request: NextRequest, { params }: { params: { directory?: string[] } }) { + return handleScimRequest(request, "GET", params.directory); +} + +export async function POST(request: NextRequest, { params }: { params: { directory?: string[] } }) { + return handleScimRequest(request, "POST", params.directory); +} + +export async function PUT(request: NextRequest, { params }: { params: { directory?: string[] } }) { + return handleScimRequest(request, "PUT", params.directory); +} + +export async function PATCH(request: NextRequest, { params }: { params: { directory?: string[] } }) { + return handleScimRequest(request, "PATCH", params.directory); +} + +export async function DELETE(request: NextRequest, { params }: { params: { directory?: string[] } }) { + return handleScimRequest(request, "DELETE", params.directory); +} + +async function handleScimRequest(request: NextRequest, method: string, directoryParams?: string[]) { + const { dsyncController } = await jackson(); + + if (!directoryParams || directoryParams.length === 0) { + return NextResponse.json({ error: "Missing directory parameters" }, { status: 400 }); + } + + const [directoryId, path, resourceId] = directoryParams; + const shouldLog = DIRECTORY_IDS_TO_LOG.includes(directoryId); + + const searchParams = request.nextUrl.searchParams; + + if (shouldLog) { + console.log( + "SCIM API request", + safeStringify({ + method, + url: request.url, + params: directoryParams, + searchParams: Object.fromEntries(searchParams.entries()), + body: request.body + ? await request + .clone() + .json() + .catch(() => ({})) + : undefined, + }) + ); + } + + let body: object | undefined = undefined; + try { + body = await request.json().catch(() => undefined); + } catch (e) { + log.error(`Error parsing SCIM event for directoryId ${directoryId} with error: ${e}`); + } + + // Handle the SCIM API requests + const scimRequest: DirectorySyncRequest = { + method, + directoryId, + resourceId, + apiSecret: extractAuthToken(request), + resourceType: path === "Users" ? "users" : "groups", + body, + query: { + count: searchParams.get("count") ? parseInt(searchParams.get("count") as string) : undefined, + startIndex: searchParams.get("startIndex") + ? parseInt(searchParams.get("startIndex") as string) + : undefined, + filter: searchParams.get("filter") as string, + }, + }; + + const { status, data } = await dsyncController.requests.handle(scimRequest, handleEvents); + + if (shouldLog) { + console.log( + "Response to SCIM", + safeStringify({ + status, + data, + }) + ); + } + + return NextResponse.json(data, { status }); +} diff --git a/apps/web/app/api/social/og/image/route.tsx b/apps/web/app/api/social/og/image/route.tsx index b2dc9212c9..0f171742f0 100644 --- a/apps/web/app/api/social/og/image/route.tsx +++ b/apps/web/app/api/social/og/image/route.tsx @@ -39,7 +39,7 @@ const genericSchema = z.object({ }); async function handler(req: NextRequest) { - const { searchParams } = new URL(`${req.url}`); + const { searchParams } = req.nextUrl; const imageType = searchParams.get("type"); const [calFontData, interFontData, interFontMediumData] = await Promise.all([ diff --git a/apps/web/app/api/sync/helpscout/route.ts b/apps/web/app/api/sync/helpscout/route.ts new file mode 100644 index 0000000000..d76b6962c6 --- /dev/null +++ b/apps/web/app/api/sync/helpscout/route.ts @@ -0,0 +1,96 @@ +import { createHmac } from "crypto"; +import { headers } from "next/headers"; +import { cookies } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import getRawBody from "raw-body"; +import z from "zod"; + +import { emailSchema } from "@calcom/lib/emailSchema"; +import { default as webPrisma } from "@calcom/prisma"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +const helpscoutRequestBodySchema = z.object({ + customer: z.object({ + email: emailSchema, + }), +}); + +/** + * API for Helpscout to retrieve key information about a user from a ticket + * Note: HelpScout expects a JSON with a `html` prop to show its content as HTML + */ +export async function POST(request: NextRequest) { + const hsSignature = request.headers.get("x-helpscout-signature"); + if (!hsSignature) return NextResponse.json({ message: "Missing signature" }, { status: 400 }); + + if (!process.env.CALENDSO_ENCRYPTION_KEY) + return NextResponse.json({ message: "Missing encryption key" }, { status: 500 }); + + const legacyRequest = buildLegacyRequest(headers(), cookies()); + + // Get the raw request body + const rawBody = await getRawBody(legacyRequest); + + try { + const parsedBody = helpscoutRequestBodySchema.safeParse(JSON.parse(rawBody.toString())); + if (!parsedBody.success) return NextResponse.json({ message: "Invalid request body" }, { status: 400 }); + + // Verify the signature + const calculatedSig = createHmac("sha1", process.env.CALENDSO_ENCRYPTION_KEY) + .update(rawBody) + .digest("base64"); + + if (hsSignature !== calculatedSig) + return NextResponse.json({ message: "Invalid signature" }, { status: 400 }); + + const user = await webPrisma.user.findFirst({ + where: { + email: parsedBody.data.customer.email, + }, + select: { + username: true, + id: true, + createdDate: true, + }, + }); + + if (!user) return NextResponse.json({ html: "User not found" }); + + const lastBooking = await webPrisma.attendee.findFirst({ + where: { + email: parsedBody.data.customer.email, + }, + select: { + booking: { + select: { + createdAt: true, + }, + }, + }, + orderBy: { + booking: { + createdAt: "desc", + }, + }, + }); + + return NextResponse.json({ + html: ` + + `, + }); + } catch (error) { + console.error("Error processing HelpScout request:", error); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/web/pages/api/teams/api/create.ts b/apps/web/app/api/teams/api/create/route.ts similarity index 57% rename from apps/web/pages/api/teams/api/create.ts rename to apps/web/app/api/teams/api/create/route.ts index d08c5ceb6a..f9b8a19ffb 100644 --- a/apps/web/pages/api/teams/api/create.ts +++ b/apps/web/app/api/teams/api/create/route.ts @@ -1,11 +1,11 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { z } from "zod"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { HttpError } from "@calcom/lib/http-error"; -import { defaultHandler } from "@calcom/lib/server/defaultHandler"; -import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { _MembershipModel as Membership, _TeamModel as Team } from "@calcom/prisma/zod"; @@ -24,49 +24,58 @@ type CheckoutSessionMetadata = z.infer; export const schemaTeamReadPublic = Team.omit({}); export const schemaMembershipPublic = Membership.merge(z.object({ team: Team }).partial()); -async function handler(req: NextApiRequest, res: NextApiResponse) { - const checkoutSession = await getCheckoutSession(req); - validateCheckoutSession(checkoutSession); - const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession); - const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession); +async function handler(request: NextRequest) { + try { + const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); - const finalizedTeam = await prisma.team.update({ - where: { id: checkoutSessionMetadata.pendingPaymentTeamId }, - data: { - pendingPayment: false, - members: { - create: { - userId: checkoutSessionMetadata.ownerId as number, - role: MembershipRole.OWNER, - accepted: true, + const checkoutSession = await getCheckoutSession(session_id); + validateCheckoutSession(checkoutSession); + const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession); + const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession); + + const finalizedTeam = await prisma.team.update({ + where: { id: checkoutSessionMetadata.pendingPaymentTeamId }, + data: { + pendingPayment: false, + members: { + create: { + userId: checkoutSessionMetadata.ownerId as number, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + metadata: { + paymentId: checkoutSession.id, + subscriptionId: checkoutSessionSubscription.id || null, + subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null, }, }, - metadata: { - paymentId: checkoutSession.id, - subscriptionId: checkoutSessionSubscription.id || null, - subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null, - }, - }, - include: { members: true }, - }); + include: { members: true }, + }); - const response = JSON.stringify( - { + const response = { message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`, team: schemaTeamReadPublic.parse(finalizedTeam), owner: schemaMembershipPublic.parse(finalizedTeam.members[0]), - }, - null, - 2 - ); + }; - return res.status(200).send(response); + return NextResponse.json(response); + } catch (error) { + console.error("Error creating team:", error); + + if (error instanceof HttpError) { + return NextResponse.json({ message: error.message }, { status: error.statusCode }); + } + + return NextResponse.json( + { message: error instanceof Error ? error.message : "An unexpected error occurred" }, + { status: 500 } + ); + } } -async function getCheckoutSession(req: NextApiRequest) { - const { session_id } = querySchema.parse(req.query); - - const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, { +async function getCheckoutSession(sessionId: string) { + const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"], }); if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" }); @@ -102,11 +111,7 @@ function getCheckoutSessionMetadata( }); } - const checkoutSessionMetadata = parseCheckoutSessionMetadata.data; - - return checkoutSessionMetadata; + return parseCheckoutSessionMetadata.data; } -export default defaultHandler({ - GET: Promise.resolve({ default: defaultResponder(handler) }), -}); +export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/pages/api/teams/googleworkspace/add.ts b/apps/web/app/api/teams/googleworkspace/add/route.ts similarity index 55% rename from apps/web/pages/api/teams/googleworkspace/add.ts rename to apps/web/app/api/teams/googleworkspace/add/route.ts index cc220e83ea..7ca4ff5ddc 100644 --- a/apps/web/pages/api/teams/googleworkspace/add.ts +++ b/apps/web/app/api/teams/googleworkspace/add/route.ts @@ -1,5 +1,6 @@ import { OAuth2Client } from "googleapis-common"; -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -9,14 +10,19 @@ const scopes = [ "https://www.googleapis.com/auth/admin.directory.customer.readonly", ]; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") { +export async function GET(request: NextRequest) { + try { // Get appKeys from google-calendar const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); + if (!client_id || typeof client_id !== "string") - return res.status(400).json({ message: "Google client_id missing." }); + return NextResponse.json({ message: "Google client_id missing." }, { status: 400 }); + if (!client_secret || typeof client_secret !== "string") - return res.status(400).json({ message: "Google client_secret missing." }); + return NextResponse.json({ message: "Google client_secret missing." }, { status: 400 }); + + // Get teamId from query params + const teamId = request.nextUrl.searchParams.get("teamId"); // use different callback to normal calendar connection const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`; @@ -25,11 +31,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const authUrl = oAuth2Client.generateAuthUrl({ access_type: "offline", scope: scopes, - prompt: "consent", - state: JSON.stringify({ teamId: req.query.teamId }), + state: JSON.stringify({ teamId }), }); - res.status(200).json({ url: authUrl }); + return NextResponse.json({ url: authUrl }); + } catch (error) { + console.error("Error generating Google Workspace auth URL:", error); + return NextResponse.json( + { message: error instanceof Error ? error.message : "An unexpected error occurred" }, + { status: 500 } + ); } } diff --git a/apps/web/app/api/teams/googleworkspace/callback/route.ts b/apps/web/app/api/teams/googleworkspace/callback/route.ts new file mode 100644 index 0000000000..3be71d23e2 --- /dev/null +++ b/apps/web/app/api/teams/googleworkspace/callback/route.ts @@ -0,0 +1,86 @@ +import { OAuth2Client } from "googleapis-common"; +import { cookies, headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import prisma from "@calcom/prisma"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +const stateSchema = z.object({ + teamId: z.string(), +}); + +export async function GET(request: NextRequest) { + try { + const headersList = headers(); + const cookiesList = cookies(); + const legacyReq = buildLegacyRequest(headersList, cookiesList); + + const session = await getServerSession({ req: legacyReq }); + + if (!session?.user?.id) { + return NextResponse.json({ message: "You must be logged in to do this" }, { status: 401 }); + } + + const code = request.nextUrl.searchParams.get("code"); + const state = request.nextUrl.searchParams.get("state"); + + if (!state) { + return NextResponse.json({ message: "No state provided" }, { status: 400 }); + } + + const parsedState = stateSchema.parse(JSON.parse(state)); + const { teamId } = parsedState; + + await throwIfNotHaveAdminAccessToTeam({ + teamId: Number(teamId) ?? null, + userId: session.user.id, + }); + + if (!code || typeof code !== "string") { + return NextResponse.json({ message: "`code` must be a string" }, { status: 400 }); + } + + const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); + + if (!client_id || typeof client_id !== "string") + return NextResponse.json({ message: "Google client_id missing." }, { status: 400 }); + + if (!client_secret || typeof client_secret !== "string") + return NextResponse.json({ message: "Google client_secret missing." }, { status: 400 }); + + const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`; + const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri); + + const credentials = await oAuth2Client.getToken(code); + + await prisma.credential.create({ + data: { + type: "google_workspace_directory", + key: credentials.res?.data, + userId: session.user.id, + }, + }); + + let redirectUrl = `${WEBAPP_URL}/settings`; + + if (teamId) { + redirectUrl = `${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`; + } + + const safeRedirectUrl = getSafeRedirectUrl(redirectUrl) ?? `${WEBAPP_URL}/teams`; + return NextResponse.redirect(safeRedirectUrl); + } catch (error) { + console.error("Error in Google Workspace callback:", error); + + // Redirect to teams page on error + return NextResponse.redirect(`${WEBAPP_URL}/teams`); + } +} diff --git a/apps/web/app/api/username/route.ts b/apps/web/app/api/username/route.ts new file mode 100644 index 0000000000..2f1fde6b35 --- /dev/null +++ b/apps/web/app/api/username/route.ts @@ -0,0 +1,33 @@ +import { cookies, headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +const bodySchema = z.object({ + username: z.string(), + orgSlug: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { username, orgSlug } = bodySchema.parse(body); + + const legacyReq = buildLegacyRequest(headers(), cookies()); + + // Get current org domain from request headers + const { currentOrgDomain } = orgDomainConfig(legacyReq); + + const result = await checkUsername(username, currentOrgDomain || orgSlug); + + return NextResponse.json(result); + } catch (error) { + console.error(error); + return NextResponse.json({ error: "Failed to check username availability" }, { status: 400 }); + } +} diff --git a/apps/web/app/api/verify-booking-token/route.ts b/apps/web/app/api/verify-booking-token/route.ts new file mode 100644 index 0000000000..d0c0a2e220 --- /dev/null +++ b/apps/web/app/api/verify-booking-token/route.ts @@ -0,0 +1,149 @@ +import { headers, cookies } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +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 { TRPCError } from "@trpc/server"; + +enum DirectAction { + ACCEPT = "accept", + REJECT = "reject", +} + +const querySchema = z.object({ + action: z.nativeEnum(DirectAction), + token: z.string(), + bookingUid: z.string(), + userId: z.string(), +}); + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries()); + + try { + const { action, token, bookingUid, userId } = querySchema.parse(queryParams); + + if (action === DirectAction.REJECT) { + // Rejections should use POST method + return NextResponse.redirect( + `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}` + ); + } + + return await handleBookingAction(action, token, bookingUid, userId, undefined, request); + } catch (error) { + const bookingUid = queryParams.bookingUid || ""; + return NextResponse.redirect( + `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}` + ); + } +} + +export async function POST(request: NextRequest) { + const url = new URL(request.url); + const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries()); + + try { + const { action, token, bookingUid, userId } = querySchema.parse(queryParams); + const body = await request.json().catch(() => ({})); + const { reason } = z.object({ reason: z.string().optional() }).parse(body || {}); + + return await handleBookingAction(action, token, bookingUid, userId, reason, request); + } catch (error) { + const bookingUid = queryParams.bookingUid || ""; + return NextResponse.redirect( + `${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}` + ); + } +} + +async function handleBookingAction( + action: DirectAction, + token: string, + bookingUid: string, + userId: string, + reason?: string, + request?: NextRequest +) { + const booking = await prisma.booking.findUnique({ + where: { oneTimePassword: token }, + }); + + if (!booking) { + return NextResponse.redirect( + `/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}` + ); + } + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: Number(userId) }, + }); + + /** We shape the session as required by tRPC router */ + async function sessionGetter() { + return { + user: { + id: Number(userId), + 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(headers(), 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, + }); + } catch (e) { + let message = "Error confirming booking"; + if (e instanceof TRPCError) message = (e as TRPCError).message; + return NextResponse.redirect(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`); + } + + await prisma.booking.update({ + where: { id: booking.id }, + data: { oneTimePassword: null }, + }); + + return NextResponse.redirect(`/booking/${booking.uid}`); +} diff --git a/apps/web/app/api/version/route.ts b/apps/web/app/api/version/route.ts new file mode 100644 index 0000000000..2312c2023a --- /dev/null +++ b/apps/web/app/api/version/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import * as pjson from "package.json"; + +export async function GET() { + return NextResponse.json({ version: pjson.version }); +} diff --git a/apps/web/app/api/webhook/app-credential/route.ts b/apps/web/app/api/webhook/app-credential/route.ts new file mode 100644 index 0000000000..c0f38d135e --- /dev/null +++ b/apps/web/app/api/webhook/app-credential/route.ts @@ -0,0 +1,103 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import z from "zod"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants"; +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import prisma from "@calcom/prisma"; + +const appCredentialWebhookRequestBodySchema = z.object({ + // UserId of the cal.com user + userId: z.number().int(), + appSlug: z.string(), + // Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY + keys: z.string(), +}); + +export async function POST(request: NextRequest) { + if (!APP_CREDENTIAL_SHARING_ENABLED) { + return NextResponse.json({ message: "Credential sharing is not enabled" }, { status: 403 }); + } + + const secretHeader = request.headers.get(CREDENTIAL_SYNC_SECRET_HEADER_NAME); + if (secretHeader !== CREDENTIAL_SYNC_SECRET) { + return NextResponse.json({ message: "Invalid credential sync secret" }, { status: 403 }); + } + + try { + const body = await request.json(); + const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(body); + + if (!reqBodyParsed.success) { + return NextResponse.json({ error: reqBodyParsed.error.issues }, { status: 400 }); + } + + const reqBody = reqBodyParsed.data; + + const user = await prisma.user.findUnique({ where: { id: reqBody.userId } }); + + if (!user) { + return NextResponse.json({ message: "User not found" }, { status: 404 }); + } + + const app = await prisma.app.findUnique({ + where: { slug: reqBody.appSlug }, + select: { dirName: true }, + }); + + if (!app) { + return NextResponse.json({ message: "App not found" }, { status: 404 }); + } + + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + + if (!appMetadata) { + return NextResponse.json( + { message: "App not found. Ensure that you have the correct app slug" }, + { status: 404 } + ); + } + + const keys = JSON.parse( + symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + // INFO: Can't use prisma upsert as we don't know the id of the credential + const appCredential = await prisma.credential.findFirst({ + where: { + userId: reqBody.userId, + appId: appMetadata.slug, + }, + select: { + id: true, + }, + }); + + if (appCredential) { + await prisma.credential.update({ + where: { + id: appCredential.id, + }, + data: { + key: keys, + }, + }); + return NextResponse.json({ message: `Credentials updated for userId: ${reqBody.userId}` }); + } else { + await prisma.credential.create({ + data: { + key: keys, + userId: reqBody.userId, + appId: appMetadata.slug, + type: appMetadata.type, + }, + }); + return NextResponse.json({ message: `Credentials created for userId: ${reqBody.userId}` }); + } + } catch (error) { + console.error("Error processing app credential webhook:", error); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts index 36d9c1be78..bc87313008 100644 --- a/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts +++ b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts @@ -9,10 +9,9 @@ import { } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects"; -import type { Request, Response } from "express"; -import type { NextApiRequest, NextApiResponse } from "next"; +import { NextRequest } from "next/server"; import { createMocks } from "node-mocks-http"; -import { describe, afterEach, test, vi, beforeEach, beforeAll } from "vitest"; +import { describe, afterEach, test, vi, beforeEach, beforeAll, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib"; @@ -20,10 +19,44 @@ import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient" import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; -import handler from "@calcom/web/pages/api/recorded-daily-video"; +import * as recordedDailyVideoRoute from "@calcom/web/app/api/recorded-daily-video/route"; + +// Mock the next/headers module before importing the handler +vi.mock("next/headers", () => ({ + headers: () => new Headers({ "content-type": "application/json" }), + cookies: () => ({ + get: () => null, + getAll: () => [], + has: () => false, + }), +})); + +// Mock NextResponse to handle the response properly +vi.mock("next/server", async () => { + const actual = (await vi.importActual("next/server")) as any; + return { + ...actual, + NextResponse: { + json: (data: any, init?: ResponseInit) => { + return new Response(JSON.stringify(data), { + ...init, + headers: { + ...init?.headers, + "content-type": "application/json", + }, + }); + }, + // Add other methods you might need + redirect: actual.NextResponse.redirect, + next: actual.NextResponse.next, + rewrite: actual.NextResponse.rewrite, + }, + }; +}); + +// Now import the handler after mocking +const { POST: handler } = recordedDailyVideoRoute; -type CustomNextApiRequest = NextApiRequest & Request; -type CustomNextApiResponse = NextApiResponse & Response; beforeAll(() => { // Setup env vars vi.stubEnv("SENDGRID_API_KEY", "FAKE_SENDGRID_API_KEY"); @@ -112,6 +145,24 @@ const TRANSCRIPTION_ACCESS_LINK = { ], }; +// We may need to make this more globally available. Will move if we need it elsewhere +function createNextRequest(mockReq: ReturnType["req"]): NextRequest { + // Create a Request object that NextRequest can wrap + const request = new Request("https://example.com/api/recorded-daily-video", { + method: mockReq.method, + headers: new Headers(mockReq.headers as Record), + body: mockReq.body ? JSON.stringify(mockReq.body) : undefined, + }); + + // Create a NextRequest from the Request + const nextRequest = new NextRequest(request, { + ip: mockReq.socket?.remoteAddress || "127.0.0.1", + geo: { city: "", country: "", region: "" }, + }); + + return nextRequest; +} + describe("Handler: /api/recorded-daily-video", () => { beforeEach(() => { fetchMock.resetMocks(); @@ -212,13 +263,23 @@ describe("Handler: /api/recorded-daily-video", () => { download_link: recordingDownloadLink, }); - const { req, res } = createMocks({ + const { req } = createMocks({ method: "POST", body: BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD, prisma, }); - await handler(req, res); + const nextReq = createNextRequest(req); + + // Handle the response differently + try { + const response = await handler(nextReq); + // For App Router, we just need to check if the response exists + expect(response).toBeDefined(); + } catch (error) { + console.error("Handler error:", error); + throw error; + } await expectWebhookToHaveBeenCalledWith(subscriberUrl, { triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, diff --git a/apps/web/pages/api/cron/calendar-cache-cleanup.ts b/apps/web/pages/api/cron/calendar-cache-cleanup.ts deleted file mode 100644 index 63d49c458e..0000000000 --- a/apps/web/pages/api/cron/calendar-cache-cleanup.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import prisma from "@calcom/prisma"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - const deleted = await prisma.calendarCache.deleteMany({ - where: { - // Delete all cache entries that expired before now - expiresAt: { - lte: new Date(Date.now()), - }, - }, - }); - - res.json({ ok: true, count: deleted.count }); -} diff --git a/apps/web/pages/api/email.ts b/apps/web/pages/api/email.ts deleted file mode 100644 index 17555b4cee..0000000000 --- a/apps/web/pages/api/email.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { renderEmail } from "@calcom/emails"; -import { IS_PRODUCTION } from "@calcom/lib/constants"; -import { getTranslation } from "@calcom/lib/server/i18n"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end(); - const t = await getTranslation("en", "common"); - - res.statusCode = 200; - - res.setHeader("Content-Type", "text/html"); - res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate"); - - res.write( - await renderEmail("MonthlyDigestEmail", { - language: t, - Created: 12, - Completed: 13, - Rescheduled: 14, - Cancelled: 16, - mostBookedEvents: [ - { - eventTypeId: 3, - eventTypeName: "Test1", - count: 3, - }, - { - eventTypeId: 4, - eventTypeName: "Test2", - count: 5, - }, - ], - membersWithMostBookings: [ - { - userId: 4, - user: { - id: 4, - name: "User1 name", - email: "email.com", - avatar: "none", - username: "User1", - }, - count: 4, - }, - { - userId: 6, - user: { - id: 6, - name: "User2 name", - email: "email2.com", - avatar: "none", - username: "User2", - }, - count: 8, - }, - ], - admin: { email: "admin.com", name: "admin" }, - team: { name: "Team1", id: 4 }, - }) - ); - res.end(); -}; - -export default handler; diff --git a/apps/web/pages/api/future-opt-in.ts b/apps/web/pages/api/future-opt-in.ts deleted file mode 100644 index 04cdcc43b3..0000000000 --- a/apps/web/pages/api/future-opt-in.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { FUTURE_ROUTES_OVERRIDE_COOKIE_NAME as COOKIE_NAME } from "@calcom/lib/constants"; -import { defaultHandler } from "@calcom/lib/server/defaultHandler"; -import { defaultResponder } from "@calcom/lib/server/defaultResponder"; - -async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - const session = await getServerSession({ req }); - - if (!session || !session.user || !session.user.email) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - let redirectUrl = "/"; - - // We take you back where you came from if possible - if (typeof req.headers["referer"] === "string") redirectUrl = req.headers["referer"]; - - // If has the cookie, Opt-out of V2 - if (COOKIE_NAME in req.cookies && req.cookies[COOKIE_NAME] === "1") { - res.setHeader("Set-Cookie", `${COOKIE_NAME}=0; Max-Age=0; Path=/`); - } else { - /* Opt-int to V2 */ - res.setHeader("Set-Cookie", `${COOKIE_NAME}=1; Path=/`); - } - - res.redirect(redirectUrl); -} - -export default defaultHandler({ - GET: Promise.resolve({ default: defaultResponder(handler) }), -}); diff --git a/apps/web/pages/api/geolocation.ts b/apps/web/pages/api/geolocation.ts deleted file mode 100644 index b5d61d6481..0000000000 --- a/apps/web/pages/api/geolocation.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - const country = req.headers["x-vercel-ip-country"] || "Unknown"; - res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"); - res.status(200).json({ country }); -} diff --git a/apps/web/pages/api/intercom-hash.ts b/apps/web/pages/api/intercom-hash.ts deleted file mode 100644 index 4ae13270d8..0000000000 --- a/apps/web/pages/api/intercom-hash.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import crypto from "node:crypto"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { defaultHandler } from "@calcom/lib/server/defaultHandler"; - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getServerSession({ req }); - const secret = process.env.INTERCOM_SECRET; - - if (!session) { - return res.status(401).json({ message: "user not authenticated" }); - } - - if (!secret) { - return res.status(400).json({ message: "Intercom Identity Verification secret not set" }); - } - - const hmac = crypto.createHmac("sha256", secret); - hmac.update(String(session.user.id)); - const hash = hmac.digest("hex"); - - return res.status(200).json({ hash }); -} - -export default defaultHandler({ - GET: Promise.resolve({ default: handler }), -}); diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts deleted file mode 100644 index 4fd1ed1773..0000000000 --- a/apps/web/pages/api/link.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -import { symmetricDecrypt } from "@calcom/lib/crypto"; -import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -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 type { UserProfile } from "@calcom/types/UserProfile"; - -import { TRPCError } from "@trpc/server"; - -enum DirectAction { - ACCEPT = "accept", - REJECT = "reject", -} - -const querySchema = z.object({ - action: z.nativeEnum(DirectAction), - token: z.string(), - reason: z.string().optional(), -}); - -const decryptedSchema = z.object({ - bookingUid: z.string(), - userId: z.number().int(), -}); - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { action, token, reason } = querySchema.parse(req.query); - const { bookingUid, userId } = decryptedSchema.parse( - JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")) - ); - - const booking = await prisma.booking.findUniqueOrThrow({ - where: { uid: bookingUid }, - }); - - const user = await prisma.user.findUniqueOrThrow({ - where: { id: userId }, - }); - - /** We shape the session as required by tRPC router */ - async function sessionGetter() { - return { - user: { - id: userId, - 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 ctx = await createContext({ req, res }, sessionGetter); - const caller = bookingsRouter.createCaller({ - ...ctx, - req, - res, - user: { ...user, locale: user?.locale ?? "en" }, - }); - await caller.confirm({ - bookingId: booking.id, - recurringEventId: booking.recurringEventId || undefined, - confirmed: action === DirectAction.ACCEPT, - reason, - }); - } catch (e) { - let message = "Error confirming booking"; - if (e instanceof TRPCError) message = (e as TRPCError).message; - res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`); - return; - } - - res.redirect(`/booking/${bookingUid}`); -} - -export default defaultResponder(handler); diff --git a/apps/web/pages/api/nope.ts b/apps/web/pages/api/nope.ts deleted file mode 100644 index ac5b83dfce..0000000000 --- a/apps/web/pages/api/nope.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -type Response = { - message: string; -}; - -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - return res.status(400).json({ message: "Please don't" }); -} diff --git a/apps/web/pages/api/scim/v2.0/[...directory].ts b/apps/web/pages/api/scim/v2.0/[...directory].ts deleted file mode 100644 index 52d6dc3d6f..0000000000 --- a/apps/web/pages/api/scim/v2.0/[...directory].ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { DirectorySyncEvent, DirectorySyncRequest } from "@boxyhq/saml-jackson"; -import type { NextApiRequest, NextApiResponse } from "next"; - -import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents"; -import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents"; -import jackson from "@calcom/features/ee/sso/lib/jackson"; -import { DIRECTORY_IDS_TO_LOG } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import prisma from "@calcom/prisma"; - -const log = logger.getSubLogger({ prefix: ["[scim]"] }); - -// This is the handler for the SCIM API requests -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { dsyncController } = await jackson(); - - const { method, query, body } = req; - - const [directoryId, path, resourceId] = query.directory as string[]; - const shouldLog = DIRECTORY_IDS_TO_LOG.includes(directoryId); - if (shouldLog) { - console.log( - "SCIM API request", - safeStringify({ - method: req.method, - url: req.url, - query: req.query, - body: req.body, - }) - ); - } - let responseBody: object | undefined = undefined; - if (body) { - try { - responseBody = JSON.parse(body); - } catch (e) { - log.error(`Error parsing SCIM event for directoryId ${directoryId} with error: ${e} and body ${body}`); - } - } - - // Handle the SCIM API requests - const request: DirectorySyncRequest = { - method: method as string, - directoryId, - resourceId, - apiSecret: extractAuthToken(req), - resourceType: path === "Users" ? "users" : "groups", - body: responseBody, - query: { - count: req.query.count ? parseInt(req.query.count as string) : undefined, - startIndex: req.query.startIndex ? parseInt(req.query.startIndex as string) : undefined, - filter: req.query.filter as string, - }, - }; - - const { status, data } = await dsyncController.requests.handle(request, handleEvents); - - if (shouldLog) { - console.log( - "Response to SCIM", - safeStringify({ - status, - data, - }) - ); - } - - res.status(status).json(data); -} - -// Fetch the auth token from the request headers -export const extractAuthToken = (req: NextApiRequest): string | null => { - const authHeader = req.headers.authorization || null; - - return authHeader ? authHeader.split(" ")[1] : null; -}; - -// Handle the SCIM events -const handleEvents = async (event: DirectorySyncEvent) => { - log.debug("handleEvents", safeStringify(event)); - const dSyncData = await prisma.dSyncData.findFirst({ - where: { - directoryId: event.directory_id, - }, - select: { - id: true, - organizationId: true, - }, - }); - - if (!dSyncData) { - throw new Error("Directory sync data not found"); - } - - const { organizationId } = dSyncData; - - if (!organizationId) { - throw new Error(`Org ID not found for dsync ${dSyncData.id}`); - } - - if (event.event.includes("group")) { - handleGroupEvents(event, organizationId); - } - - if (event.event === "user.created" || event.event === "user.updated") { - await handleUserEvents(event, organizationId); - } -}; diff --git a/apps/web/pages/api/sync/helpscout/index.ts b/apps/web/pages/api/sync/helpscout/index.ts deleted file mode 100644 index 3931f75758..0000000000 --- a/apps/web/pages/api/sync/helpscout/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createHmac } from "crypto"; -import type { NextApiRequest, NextApiResponse } from "next"; -import getRawBody from "raw-body"; -import z from "zod"; - -import { emailSchema } from "@calcom/lib/emailSchema"; -import { default as webPrisma } from "@calcom/prisma"; - -export const config = { - api: { - bodyParser: false, - }, -}; - -const helpscoutRequestBodySchema = z.object({ - customer: z.object({ - email: emailSchema, - }), -}); - -/** - * API for Helpscout to retrieve key information about a user from a ticket - * Note: HelpScout expects a JSON with a `html` prop to show its content as HTML - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); - - const hsSignature = req.headers["x-helpscout-signature"]; - if (!hsSignature) return res.status(400).end(); - - if (!process.env.CALENDSO_ENCRYPTION_KEY) return res.status(500).end(); - - const rawBody = await getRawBody(req); - const parsedBody = helpscoutRequestBodySchema.safeParse(JSON.parse(rawBody.toString())); - - if (!parsedBody.success) return res.status(400).end(); - - const calculatedSig = createHmac("sha1", process.env.CALENDSO_ENCRYPTION_KEY) - .update(rawBody) - .digest("base64"); - - if (req.headers["x-helpscout-signature"] !== calculatedSig) return res.status(400).end(); - - const user = await webPrisma.user.findFirst({ - where: { - email: parsedBody.data.customer.email, - }, - select: { - username: true, - id: true, - createdDate: true, - }, - }); - - if (!user) return res.status(200).json({ html: "User not found" }); - - const lastBooking = await webPrisma.attendee.findFirst({ - where: { - email: parsedBody.data.customer.email, - }, - select: { - booking: { - select: { - createdAt: true, - }, - }, - }, - orderBy: { - booking: { - createdAt: "desc", - }, - }, - }); - - return res.status(200).json({ - html: ` -
    -
  • Username: ${user.username}
  • -
  • Last booking: ${ - lastBooking && lastBooking.booking - ? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US") - : "No info" - }
  • -
  • Account created: ${new Date(user.createdDate).toLocaleDateString("en-US")}
  • -
- `, - }); -} diff --git a/apps/web/pages/api/teams/googleworkspace/callback.ts b/apps/web/pages/api/teams/googleworkspace/callback.ts deleted file mode 100644 index ce41d84b57..0000000000 --- a/apps/web/pages/api/teams/googleworkspace/callback.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { OAuth2Client } from "googleapis-common"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; -import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; -import prisma from "@calcom/prisma"; - -const stateSchema = z.object({ - teamId: z.string(), -}); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getServerSession({ req }); - - if (!session?.user?.id) { - return res.status(401).json({ message: "You must be logged in to do this" }); - } - - const { code, state } = req.query; - const parsedState = stateSchema.parse(JSON.parse(state as string)); - const { teamId } = parsedState; - await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: session.user.id }); - if (code && typeof code !== "string") { - res.status(400).json({ message: "`code` must be a string" }); - return; - } - - const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); - - if (!client_id || typeof client_id !== "string") - return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret || typeof client_secret !== "string") - return res.status(400).json({ message: "Google client_secret missing." }); - - const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`; - const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri); - - if (!code) { - throw new Error("No code provided"); - } - - const credentials = await oAuth2Client.getToken(code); - - await prisma.credential.create({ - data: { - type: "google_workspace_directory", - key: credentials.res?.data, - userId: session.user.id, - }, - }); - - if (!teamId) { - res.redirect(getSafeRedirectUrl(`${WEBAPP_URL}/settings`) ?? `${WEBAPP_URL}/teams`); - } - - res.redirect( - getSafeRedirectUrl(`${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ?? - `${WEBAPP_URL}/teams` - ); -} diff --git a/apps/web/pages/api/username.ts b/apps/web/pages/api/username.ts deleted file mode 100644 index 19315208a0..0000000000 --- a/apps/web/pages/api/username.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { checkUsername } from "@calcom/lib/server/checkUsername"; - -type Response = { - available: boolean; - premium: boolean; -}; - -const bodySchema = z.object({ - username: z.string(), - orgSlug: z.string().optional(), -}); - -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - const { currentOrgDomain } = orgDomainConfig(req); - const { username, orgSlug } = bodySchema.parse(req.body); - const result = await checkUsername(username, currentOrgDomain || orgSlug); - return res.status(200).json(result); -} diff --git a/apps/web/pages/api/verify-booking-token.ts b/apps/web/pages/api/verify-booking-token.ts deleted file mode 100644 index 1a661a0f7b..0000000000 --- a/apps/web/pages/api/verify-booking-token.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -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 { TRPCError } from "@trpc/server"; - -enum DirectAction { - ACCEPT = "accept", - REJECT = "reject", -} - -const querySchema = z.object({ - action: z.nativeEnum(DirectAction), - token: z.string(), - bookingUid: z.string(), - userId: z.string(), -}); - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { action, token, bookingUid, userId } = querySchema.parse(req.query); - // Rejections runs on a POST request, confirming on a GET request. - const { reason } = z.object({ reason: z.string().optional() }).parse(req.body || {}); - - const booking = await prisma.booking.findUnique({ - where: { oneTimePassword: token }, - }); - - if (!booking) { - res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`); - return; - } - - const user = await prisma.user.findUniqueOrThrow({ - where: { id: Number(userId) }, - }); - - /** We shape the session as required by tRPC router */ - async function sessionGetter() { - return { - user: { - id: Number(userId), - 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); - const ctx = await createContext({ req, res }, sessionGetter); - const caller = createCaller({ - ...ctx, - req, - 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, - }); - } catch (e) { - let message = "Error confirming booking"; - if (e instanceof TRPCError) message = (e as TRPCError).message; - res.redirect(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`); - return; - } - - await prisma.booking.update({ - where: { id: booking.id }, - data: { oneTimePassword: null }, - }); - - res.redirect(`/booking/${booking.uid}`); -} - -export default defaultResponder(handler); diff --git a/apps/web/pages/api/version.ts b/apps/web/pages/api/version.ts deleted file mode 100644 index 9b05eef8ab..0000000000 --- a/apps/web/pages/api/version.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import * as pjson from "package.json"; - -type Response = { - version: string; -}; - -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - return res.status(200).json({ version: pjson.version }); -} diff --git a/apps/web/pages/api/webhook/app-credential.ts b/apps/web/pages/api/webhook/app-credential.ts deleted file mode 100644 index 527b9232c3..0000000000 --- a/apps/web/pages/api/webhook/app-credential.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import z from "zod"; - -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants"; -import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; -import { symmetricDecrypt } from "@calcom/lib/crypto"; -import prisma from "@calcom/prisma"; - -const appCredentialWebhookRequestBodySchema = z.object({ - // UserId of the cal.com user - userId: z.number().int(), - appSlug: z.string(), - // Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY - keys: z.string(), -}); -/** */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!APP_CREDENTIAL_SHARING_ENABLED) { - return res.status(403).json({ message: "Credential sharing is not enabled" }); - } - - if (req.headers[CREDENTIAL_SYNC_SECRET_HEADER_NAME] !== CREDENTIAL_SYNC_SECRET) { - return res.status(403).json({ message: "Invalid credential sync secret" }); - } - - const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(req.body); - if (!reqBodyParsed.success) { - return res.status(400).json({ error: reqBodyParsed.error.issues }); - } - - const reqBody = reqBodyParsed.data; - - const user = await prisma.user.findUnique({ where: { id: reqBody.userId } }); - - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const app = await prisma.app.findUnique({ - where: { slug: reqBody.appSlug }, - select: { dirName: true }, - }); - - if (!app) { - return res.status(404).json({ message: "App not found" }); - } - - const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; - - if (!appMetadata) { - return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" }); - } - - const keys = JSON.parse( - symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") - ); - - // INFO: Can't use prisma upsert as we don't know the id of the credential - const appCredential = await prisma.credential.findFirst({ - where: { - userId: reqBody.userId, - appId: appMetadata.slug, - }, - select: { - id: true, - }, - }); - - if (appCredential) { - await prisma.credential.update({ - where: { - id: appCredential.id, - }, - data: { - key: keys, - }, - }); - return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` }); - } else { - await prisma.credential.create({ - data: { - key: keys, - userId: reqBody.userId, - appId: appMetadata.slug, - type: appMetadata.type, - }, - }); - return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` }); - } -}