* fix(auth): validate user before signup with invite token
Validate if user already exists before creating account when
signing up with team or organization invite tokens. Existing users
are redirected to login to accept the invitation.
- Add user existence check in signup handlers
- Return 409 for existing users with redirect to login
- Extract signup fetch logic to dedicated module
- Add e2e test coverage
* fix(auth): address code review feedback
- Fix fetchSignup tests to use vi.spyOn for proper mock restoration
- Add content-type validation before parsing JSON response
- Guard against undefined error in Stripe callback
- Use t() for localized error message
- Fix race condition in handlers by catching P2002 on create
* fix(auth): address additional code review feedback
- Add INVALID_SERVER_RESPONSE constant to follow established pattern
- Check error.meta.target includes email before returning USER_ALREADY_EXISTS
to avoid false positives from other unique constraint violations
- Add select: { id: true } to user.create calls since downstream functions only
need the user id
* test: add unit tests for P2002 handling in signup handlers
- Add shared test suite covering all P2002 edge cases
- Ensure 409 only for email constraint violations
- Fix non-token paths to use atomic create + catch pattern
* fix: update error message copy per review feedback
* fix(auth): address code review feedback and prevent orphan Stripe customers
- Add user existence check before Stripe customer creation (token flow)
- Add select clause to user.create for consistency
- Fix showToast argument order (pre-existing bug)
- Use toHaveURL instead of waitForURL in E2E tests
* fix(auth): resolve 500 errors by fixing Prisma error detection across module boundaries
The instanceof check for PrismaClientKnownRequestError fails when different
Prisma client instances are loaded. Added fallback check by constructor name
* fix(auth): validate invitedTo before upsert on team invite signup
* test(auth): update P2002 tests for new invite flow
P2002 tests now use non-token flow since token flow uses upsert
Added tests for invitedTo validation on invite signup
* fix(auth): add guards and P2002 handling per review feedback
- Guard existingUser check with if (foundToken?.teamId)
- Guard username check with if (username) for premium flow
- Add `select` clause to findFirst/findUnique queries
- Add try-catch on upsert for race condition P2002 errors
* fix(auth): narrow P2002 handling to email/username targets
210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
import type { ZodIssue } from "zod";
|
|
import { ZodError } from "zod";
|
|
|
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
|
|
import { HttpError } from "../http-error";
|
|
import { redactError } from "../redactError";
|
|
import { stripeInvalidRequestErrorSchema } from "../stripe-error";
|
|
import { TracedError } from "../tracing/error";
|
|
|
|
function hasName(cause: unknown): cause is { name: string } {
|
|
return !!cause && typeof cause === "object" && "name" in cause;
|
|
}
|
|
|
|
function isZodError(cause: unknown): cause is ZodError {
|
|
return cause instanceof ZodError || (hasName(cause) && cause.name === "ZodError");
|
|
}
|
|
|
|
// Fallback to code check when instanceof fails due to different Prisma client instances
|
|
function isPrismaError(cause: unknown): cause is Prisma.PrismaClientKnownRequestError {
|
|
return (
|
|
cause instanceof Prisma.PrismaClientKnownRequestError ||
|
|
(cause instanceof Error && "code" in cause && typeof cause.code === "string" && cause.code.startsWith("P"))
|
|
);
|
|
}
|
|
|
|
function parseZodErrorIssues(issues: ZodIssue[]): string {
|
|
return issues
|
|
.map((i) =>
|
|
i.code === "invalid_union"
|
|
? i.unionErrors.map((ue) => parseZodErrorIssues(ue.issues)).join("; ")
|
|
: i.code === "unrecognized_keys"
|
|
? i.message
|
|
: `${i.path.length ? `${i.code} in '${i.path}': ` : ""}${i.message}`
|
|
)
|
|
.join("; ");
|
|
}
|
|
|
|
/**
|
|
* Converts unknown error types to HttpError with proper status code mapping and error redaction.
|
|
* SERVER-ONLY: This function imports Prisma and Stripe schemas and should only be used in server-side code.
|
|
* Use in API routes, webhooks, and server-side services.
|
|
* For client-side code, use getErrorFromUnknown from @calcom/lib/errors instead.
|
|
*
|
|
* NOTE: This function does NOT handle TRPCError. Callers that need to handle TRPCError should do so
|
|
* explicitly before calling this function (see onErrorHandler.ts and defaultResponder.ts for examples).
|
|
*/
|
|
export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
|
let traceId: string | undefined;
|
|
let tracedData: Record<string, unknown> | undefined;
|
|
|
|
if (cause instanceof TracedError) {
|
|
traceId = cause.traceId;
|
|
tracedData = cause.data;
|
|
cause = cause.originalError;
|
|
}
|
|
|
|
if (isZodError(cause)) {
|
|
return new HttpError({
|
|
statusCode: 400,
|
|
message: parseZodErrorIssues(cause.issues),
|
|
cause,
|
|
data: traceId ? { ...tracedData, traceId } : undefined,
|
|
});
|
|
}
|
|
if (cause instanceof SyntaxError) {
|
|
return new HttpError({
|
|
statusCode: 500,
|
|
message: "Unexpected error, please reach out for our customer support.",
|
|
cause,
|
|
data: traceId ? { ...tracedData, traceId } : undefined,
|
|
});
|
|
}
|
|
if (isPrismaError(cause)) {
|
|
return getServerErrorFromPrismaError(cause, traceId, tracedData);
|
|
}
|
|
const parsedStripeError = stripeInvalidRequestErrorSchema.safeParse(cause);
|
|
if (parsedStripeError.success) {
|
|
const stripeErrorObj = new Error(parsedStripeError.data.message || "Stripe error");
|
|
stripeErrorObj.name = parsedStripeError.data.type || "StripeInvalidRequestError";
|
|
return getHttpError({ statusCode: 400, cause: stripeErrorObj, traceId, tracedData });
|
|
}
|
|
if (cause instanceof ErrorWithCode) {
|
|
const statusCode = getHttpStatusCode(cause);
|
|
return new HttpError({
|
|
statusCode,
|
|
message: cause.message ?? "",
|
|
data: traceId ? { ...cause.data, ...tracedData, traceId } : cause.data,
|
|
cause,
|
|
});
|
|
}
|
|
if (cause instanceof HttpError) {
|
|
const originalData = cause.data;
|
|
return new HttpError({
|
|
statusCode: cause.statusCode,
|
|
message: cause.message ?? "",
|
|
cause: cause.cause,
|
|
url: cause.url,
|
|
method: cause.method,
|
|
data: traceId ? { ...originalData, ...tracedData, traceId } : originalData,
|
|
});
|
|
}
|
|
if (cause instanceof Error) {
|
|
const statusCode = getHttpStatusCode(cause);
|
|
return getHttpError({ statusCode, cause, traceId, tracedData });
|
|
}
|
|
if (typeof cause === "string") {
|
|
return new HttpError({
|
|
statusCode: 500,
|
|
message: cause,
|
|
data: traceId ? { ...tracedData, traceId } : undefined,
|
|
});
|
|
}
|
|
|
|
return new HttpError({
|
|
statusCode: 500,
|
|
message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`,
|
|
data: traceId ? { ...tracedData, traceId } : tracedData,
|
|
});
|
|
}
|
|
|
|
export function getHttpStatusCode(cause: Error | ErrorWithCode): number {
|
|
const errorCode = cause instanceof ErrorWithCode ? cause.code : cause.message;
|
|
|
|
switch (errorCode) {
|
|
// Generic HTTP error codes
|
|
case ErrorCode.BadRequest:
|
|
return 400;
|
|
case ErrorCode.Unauthorized:
|
|
return 401;
|
|
case ErrorCode.Forbidden:
|
|
return 403;
|
|
case ErrorCode.NotFound:
|
|
return 404;
|
|
case ErrorCode.InternalServerError:
|
|
return 500;
|
|
|
|
// Domain-specific error codes
|
|
// 400 Bad Request
|
|
case ErrorCode.RequestBodyWithouEnd:
|
|
case ErrorCode.MissingPaymentCredential:
|
|
case ErrorCode.MissingPaymentAppId:
|
|
case ErrorCode.AvailabilityNotFoundInSchedule:
|
|
case ErrorCode.CancelledBookingsCannotBeRescheduled:
|
|
case ErrorCode.BookingTimeOutOfBounds:
|
|
case ErrorCode.BookingNotAllowedByRestrictionSchedule:
|
|
case ErrorCode.BookerLimitExceeded:
|
|
case ErrorCode.BookerLimitExceededReschedule:
|
|
case ErrorCode.EventTypeNoHosts:
|
|
case ErrorCode.RequestBodyInvalid:
|
|
case ErrorCode.ChargeCardFailure:
|
|
return 400;
|
|
// 409 Conflict
|
|
case ErrorCode.NoAvailableUsersFound:
|
|
case ErrorCode.FixedHostsUnavailableForBooking:
|
|
case ErrorCode.RoundRobinHostsUnavailableForBooking:
|
|
case ErrorCode.AlreadySignedUpForBooking:
|
|
case ErrorCode.BookingSeatsFull:
|
|
case ErrorCode.NotEnoughAvailableSeats:
|
|
case ErrorCode.BookingConflict:
|
|
case ErrorCode.PaymentCreationFailure:
|
|
return 409;
|
|
// 404 Not Found
|
|
case ErrorCode.EventTypeNotFound:
|
|
case ErrorCode.BookingNotFound:
|
|
case ErrorCode.RestrictionScheduleNotFound:
|
|
return 404;
|
|
case ErrorCode.UnableToSubscribeToThePlatform:
|
|
case ErrorCode.UpdatingOauthClientError:
|
|
case ErrorCode.CreatingOauthClientError:
|
|
default:
|
|
return 500;
|
|
}
|
|
}
|
|
|
|
function getHttpError<T extends Error>({
|
|
statusCode,
|
|
cause,
|
|
traceId,
|
|
tracedData,
|
|
}: {
|
|
statusCode: number;
|
|
cause: T;
|
|
traceId?: string;
|
|
tracedData?: Record<string, unknown>;
|
|
}) {
|
|
const redacted = redactError(cause);
|
|
return new HttpError({
|
|
statusCode,
|
|
message: redacted.message,
|
|
cause: redacted,
|
|
data: traceId ? { ...tracedData, traceId } : undefined,
|
|
});
|
|
}
|
|
|
|
function getServerErrorFromPrismaError(
|
|
cause: Prisma.PrismaClientKnownRequestError,
|
|
traceId?: string,
|
|
tracedData?: Record<string, unknown>
|
|
) {
|
|
if (cause.code === "P2025") {
|
|
return getHttpError({ statusCode: 404, cause, traceId, tracedData });
|
|
}
|
|
return getHttpError({ statusCode: 400, cause, traceId, tracedData });
|
|
}
|
|
|
|
export { isPrismaError };
|