From 1277f5e9f71bf0df86a5f065abb2ebf057debd02 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Mon, 12 May 2025 03:38:03 -0400 Subject: [PATCH] chore: migrate signup API to appDir (#19975) * refactor calcomHandler * refactor selfHostedHandler * fix * migrate to appdir * fix type checks --- .../api/auth/signup/route.ts} | 36 ++++---- .../auth/signup/handlers/calcomHandler.ts | 36 ++++---- .../auth/signup/handlers/selfHostedHandler.ts | 32 ++++---- packages/lib/server/username.ts | 82 +++++++++---------- 4 files changed, 89 insertions(+), 97 deletions(-) rename apps/web/{pages/api/auth/signup.ts => app/api/auth/signup/route.ts} (68%) diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/app/api/auth/signup/route.ts similarity index 68% rename from apps/web/pages/api/auth/signup.ts rename to apps/web/app/api/auth/signup/route.ts index 891faf4e95..bf011382a8 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/app/api/auth/signup/route.ts @@ -1,8 +1,9 @@ -import type { NextApiResponse } from "next"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import { parseRequestData } from "app/api/parseRequestData"; +import { NextResponse, type NextRequest } from "next/server"; import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler"; import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler"; -import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants"; import getIP from "@calcom/lib/getIP"; @@ -11,12 +12,12 @@ import logger from "@calcom/lib/logger"; import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken"; import { signupSchema } from "@calcom/prisma/zod-utils"; -async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) { +async function ensureSignupIsEnabled(body: Record) { const { token } = signupSchema .pick({ token: true, }) - .parse(req.body); + .parse(body); // Still allow signups if there is a team invite if (token) return; @@ -32,26 +33,17 @@ async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) { } } -function ensureReqIsPost(req: RequestWithUsernameStatus) { - if (req.method !== "POST") { - throw new HttpError({ - statusCode: 405, - message: "Method not allowed", - }); - } -} - -export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { +async function handler(req: NextRequest) { const remoteIp = getIP(req); // Use a try catch instead of returning res every time try { + const body = await parseRequestData(req); await checkCfTurnstileToken({ - token: req.headers["cf-access-token"] as string, + token: req.headers.get("cf-access-token") as string, remoteIp, }); - ensureReqIsPost(req); - await ensureSignupIsEnabled(req); + await ensureSignupIsEnabled(body); /** * Im not sure its worth merging these two handlers. They are different enough to be separate. @@ -61,15 +53,17 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA * @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI. */ if (IS_PREMIUM_USERNAME_ENABLED) { - return await calcomSignupHandler(req, res); + return await calcomSignupHandler(body); } - return await selfHostedSignupHandler(req, res); + return await selfHostedSignupHandler(body); } catch (e) { if (e instanceof HttpError) { - return res.status(e.statusCode).json({ message: e.message }); + return NextResponse.json({ message: e.message }, { status: e.statusCode }); } logger.error(e); - return res.status(500).json({ message: "Internal server error" }); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); } } + +export const POST = defaultResponderForAppDir(handler); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index d9189c5810..f1bce058f5 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -1,4 +1,5 @@ -import type { NextApiResponse } from "next"; +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; @@ -11,12 +12,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; -import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username"; +import type { CustomNextApiHandler } from "@calcom/lib/server/username"; +import { usernameHandler } from "@calcom/lib/server/username"; import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername"; import { prisma } from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; import { IdentityProvider } from "@calcom/prisma/enums"; import { signupSchema } from "@calcom/prisma/zod-utils"; +import { buildLegacyRequest } from "@calcom/web/lib/buildLegacyCtx"; import { joinAnyChildTeamOnOrgInvite } from "../utils/organization"; import { @@ -27,7 +30,7 @@ import { const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] }); -async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { +const handler: CustomNextApiHandler = async (body, usernameStatus) => { const { email: _email, password, @@ -38,7 +41,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { password: true, token: true, }) - .parse(req.body); + .parse(body); const billingService = new StripeBillingService(); @@ -46,12 +49,12 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { log.debug("handler", { email: _email }); - let username: string | null = req.usernameStatus.requestedUserName; + let username: string | null = usernameStatus.requestedUserName; let checkoutSessionId: string | null = null; // Check for premium username - if (req.usernameStatus.statusCode === 418) { - return res.status(req.usernameStatus.statusCode).json(req.usernameStatus.json); + if (usernameStatus.statusCode === 418) { + return NextResponse.json(usernameStatus.json, { status: 418 }); } // Validate the user @@ -110,7 +113,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { const returnUrl = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=/auth/verify?sessionId={CHECKOUT_SESSION_ID}`; // Pro username, must be purchased - if (req.usernameStatus.statusCode === 402) { + if (usernameStatus.statusCode === 402) { const checkoutSession = await billingService.createSubscriptionCheckout({ mode: "subscription", customerId: customer.stripeCustomerId, @@ -207,20 +210,23 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { } sendEmailVerification({ email, - language: await getLocaleFromRequest(req), + language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())), username: username || "", }); } if (checkoutSessionId) { console.log("Created user but missing payment", checkoutSessionId); - return res.status(402).json({ - message: "Created user but missing payment", - checkoutSessionId, - }); + return NextResponse.json( + { message: "Created user but missing payment", checkoutSessionId }, + { status: 402 } + ); } - return res.status(201).json({ message: "Created user", stripeCustomerId: customer.stripeCustomerId }); -} + return NextResponse.json( + { message: "Created user", stripeCustomerId: customer.stripeCustomerId }, + { status: 201 } + ); +}; export default usernameHandler(handler); diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts index 8f1c7ff78b..5da0c05c53 100644 --- a/packages/features/auth/signup/handlers/selfHostedHandler.ts +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -1,4 +1,4 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { NextResponse } from "next/server"; import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; @@ -21,16 +21,14 @@ import { validateAndGetCorrectedUsernameForTeam, } from "../utils/token"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const data = req.body; - const { email, password, language, token } = signupSchema.parse(data); +export default async function handler(body: Record) { + const { email, password, language, token } = signupSchema.parse(body); - const username = slugify(data.username); + const username = slugify(body.username); const userEmail = email.toLowerCase(); if (!username) { - res.status(422).json({ message: "Invalid username" }); - return; + return NextResponse.json({ message: "Invalid username" }, { status: 422 }); } let foundToken: { id: number; teamId: number | null; expires: Date } | null = null; @@ -52,10 +50,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (!userValidation.isValid) { logger.error("User validation failed", { userValidation }); - return res.status(409).json({ message: "Username or email is already taken" }); + return NextResponse.json({ message: "Username or email is already taken" }, { status: 409 }); } if (!userValidation.username) { - return res.status(422).json({ message: "Invalid username" }); + return NextResponse.json({ message: "Invalid username" }, { status: 422 }); } correctedUsername = userValidation.username; } @@ -86,8 +84,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (isCheckingUsernameInGlobalNamespace) { const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername)); if (!isUsernameAvailable) { - res.status(409).json({ message: "A user exists with that username" }); - return; + return NextResponse.json({ message: "A user exists with that username" }, { status: 409 }); } } @@ -135,16 +132,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername)); if (!isUsernameAvailable) { - res.status(409).json({ message: "A user exists with that username" }); - return; + return NextResponse.json({ message: "A user exists with that username" }, { status: 409 }); } if (IS_PREMIUM_USERNAME_ENABLED) { const checkUsername = await checkPremiumUsername(correctedUsername); if (checkUsername.premium) { - res.status(422).json({ - message: "Sign up from https://cal.com/signup to claim your premium username", - }); - return; + return NextResponse.json( + { message: "Sign up from https://cal.com/signup to claim your premium username" }, + { status: 422 } + ); } } await prisma.user.upsert({ @@ -179,5 +175,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - res.status(201).json({ message: "Created user" }); + return NextResponse.json({ message: "Created user" }, { status: 201 }); } diff --git a/packages/lib/server/username.ts b/packages/lib/server/username.ts index 79ed40cf0b..aaaf261a83 100644 --- a/packages/lib/server/username.ts +++ b/packages/lib/server/username.ts @@ -1,4 +1,4 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextResponse } from "next/server"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; @@ -11,30 +11,28 @@ import notEmpty from "../notEmpty"; const log = logger.getSubLogger({ prefix: ["server/username"] }); const cachedData: Set = new Set(); -export type RequestWithUsernameStatus = NextApiRequest & { - usernameStatus: { - /** - * ```text - * 200: Username is available - * 402: Pro username, must be purchased - * 418: A user exists with that username - * ``` - */ - statusCode: 200 | 402 | 418; - requestedUserName: string; - json: { - available: boolean; - premium: boolean; - message?: string; - suggestion?: string; - }; +type UsernameStatus = { + /** + * ```text + * 200: Username is available + * 402: Pro username, must be purchased + * 418: A user exists with that username + * ``` + */ + statusCode: 200 | 402 | 418; + requestedUserName: string; + json: { + available: boolean; + premium: boolean; + message?: string; + suggestion?: string; }; }; -type CustomNextApiHandler = ( - req: RequestWithUsernameStatus, - res: NextApiResponse -) => void | Promise; +export type CustomNextApiHandler = ( + body: Record, + usernameStatus: UsernameStatus +) => Promise>; export async function isBlacklisted(username: string) { // NodeJS forEach is very, very fast (these days) so even though we only have to construct the Set @@ -66,7 +64,7 @@ export const generateUsernameSuggestion = async (users: string[], username: stri const processResult = ( result: "ok" | "username_exists" | "is_premium" ): // explicitly assign return value to ensure statusCode is typehinted -{ statusCode: RequestWithUsernameStatus["usernameStatus"]["statusCode"]; message: string } => { +{ statusCode: UsernameStatus["statusCode"]; message: string } => { // using a switch statement instead of multiple ifs to make sure typescript knows // there is only limited options switch (result) { @@ -85,29 +83,27 @@ const processResult = ( } }; -const usernameHandler = - (handler: CustomNextApiHandler) => - async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise => { - const username = slugify(req.body.username); - const check = await usernameCheckForSignup({ username, email: req.body.email }); +const usernameHandler = (handler: CustomNextApiHandler) => async (body: Record) => { + const username = slugify(body.username); + const check = await usernameCheckForSignup({ username, email: body.email }); - let result: Parameters[0] = "ok"; - if (check.premium) result = "is_premium"; - if (!check.available) result = "username_exists"; + let result: Parameters[0] = "ok"; + if (check.premium) result = "is_premium"; + if (!check.available) result = "username_exists"; - const { statusCode, message } = processResult(result); - req.usernameStatus = { - statusCode, - requestedUserName: username, - json: { - available: result !== "username_exists", - premium: result === "is_premium", - message, - suggestion: check.suggestedUsername, - }, - }; - return handler(req, res); + const { statusCode, message } = processResult(result); + const usernameStatus = { + statusCode, + requestedUserName: username, + json: { + available: result !== "username_exists", + premium: result === "is_premium", + message, + suggestion: check.suggestedUsername, + }, }; + return handler(body, usernameStatus); +}; const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | null) => { log.debug("usernameCheck", { usernameRaw, currentOrgDomain });