chore: migrate signup API to appDir (#19975)

* refactor calcomHandler

* refactor selfHostedHandler

* fix

* migrate to appdir

* fix type checks
This commit is contained in:
Benny Joo
2025-05-12 09:38:03 +02:00
committed by GitHub
parent 109c303b60
commit 1277f5e9f7
4 changed files with 89 additions and 97 deletions
@@ -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<string, string>) {
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);
@@ -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);
@@ -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<string, string>) {
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 });
}
+39 -43
View File
@@ -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<string> = 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<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
export type CustomNextApiHandler = (
body: Record<string, string>,
usernameStatus: UsernameStatus
) => Promise<NextResponse<any>>;
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<void> => {
const username = slugify(req.body.username);
const check = await usernameCheckForSignup({ username, email: req.body.email });
const usernameHandler = (handler: CustomNextApiHandler) => async (body: Record<string, string>) => {
const username = slugify(body.username);
const check = await usernameCheckForSignup({ username, email: body.email });
let result: Parameters<typeof processResult>[0] = "ok";
if (check.premium) result = "is_premium";
if (!check.available) result = "username_exists";
let result: Parameters<typeof processResult>[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 });