diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx index 5698aa509c..dfd12eee5c 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx @@ -6,11 +6,10 @@ import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing" import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { TeamService } from "@calcom/features/ee/teams/services/teamService"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { ErrorWithCode } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - import { TeamsCTA } from "./CTA"; const getCachedTeams = unstable_cache( @@ -52,7 +51,7 @@ export const ServerTeamsListing = async ({ } } catch (e) { errorMsgFromInvite = "Error while fetching teams"; - if (e instanceof TRPCError) errorMsgFromInvite = e.message; + if (e instanceof ErrorWithCode) errorMsgFromInvite = e.message; } } diff --git a/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts b/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts index 41f5d844e4..656e217078 100644 --- a/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts +++ b/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts @@ -1,8 +1,8 @@ import { canAccessOrganization } from "@calcom/features/ee/sso/lib/saml"; import prisma from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; const userCanCreateTeamGroupMapping = async ( user: NonNullable, @@ -10,18 +10,12 @@ const userCanCreateTeamGroupMapping = async ( teamId?: number ) => { if (!organizationId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Could not find organization id", - }); + throw new ErrorWithCode(ErrorCode.BadRequest, "Could not find organization id"); } const { message, access } = await canAccessOrganization(user, organizationId); if (!access) { - throw new TRPCError({ - code: "BAD_REQUEST", - message, - }); + throw new ErrorWithCode(ErrorCode.BadRequest, message); } if (teamId) { @@ -36,10 +30,7 @@ const userCanCreateTeamGroupMapping = async ( }); if (!orgTeam) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Could not find team", - }); + throw new ErrorWithCode(ErrorCode.BadRequest, "Could not find team"); } } diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index 6c5b004e31..9fa9af0111 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -13,8 +13,8 @@ import { prisma } from "@calcom/prisma"; import type { OrganizationOnboarding } from "@calcom/prisma/client"; import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums"; import { userMetadata } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { OrganizationPermissionService } from "./OrganizationPermissionService"; import type { OnboardingUser } from "./service/onboarding/types"; @@ -180,10 +180,10 @@ export class OrganizationPaymentService { this.permissionService.hasModifiedDefaultPayment(input) && !this.permissionService.hasPermissionToModifyDefaultPayment() ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You do not have permission to modify the default payment settings", - }); + throw new ErrorWithCode( + ErrorCode.Unauthorized, + "You do not have permission to modify the default payment settings" + ); } await this.permissionService.validatePermissions(input); diff --git a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts index 2c1d45dd7e..96b36cbc0e 100644 --- a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts @@ -5,8 +5,8 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import type { OnboardingUser } from "./service/onboarding/types"; @@ -102,25 +102,19 @@ export class OrganizationPermissionService { } & SeatsPrice ): Promise { if (!(await this.hasPermissionToCreateForEmail(input.orgOwnerEmail))) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "you_do_not_have_permission_to_create_an_organization_for_this_email", - }); + throw new ErrorWithCode( + ErrorCode.Unauthorized, + "you_do_not_have_permission_to_create_an_organization_for_this_email" + ); } if (await this.hasConflictingOrganization({ slug: input.slug })) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "organization_already_exists_with_this_slug", - }); + throw new ErrorWithCode(ErrorCode.BadRequest, "organization_already_exists_with_this_slug"); } if (await this.hasCompletedOnboarding(input.orgOwnerEmail)) { // TODO: Consider redirecting to success page - throw new TRPCError({ - code: "BAD_REQUEST", - message: "organization_onboarding_already_completed", - }); + throw new ErrorWithCode(ErrorCode.BadRequest, "organization_onboarding_already_completed"); } const teamsToMigrate = input.teams @@ -128,10 +122,10 @@ export class OrganizationPermissionService { .map((team) => team.id); if (teamsToMigrate && !(await this.hasPermissionToMigrateTeams(teamsToMigrate))) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "you_do_not_have_permission_to_migrate_one_or_more_of_the_teams", - }); + throw new ErrorWithCode( + ErrorCode.Unauthorized, + "you_do_not_have_permission_to_migrate_one_or_more_of_the_teams" + ); } return true; diff --git a/packages/features/ee/sso/lib/sso.ts b/packages/features/ee/sso/lib/sso.ts index 0a80acc3f4..f8bb22318b 100644 --- a/packages/features/ee/sso/lib/sso.ts +++ b/packages/features/ee/sso/lib/sso.ts @@ -3,8 +3,8 @@ import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/ import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import type { PrismaClient } from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import jackson from "./jackson"; import { tenantPrefix, samlProductID } from "./saml"; @@ -29,21 +29,13 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { let memberships = await getAllAcceptedMemberships({ prisma, email }); if (!memberships || memberships.length === 0) { - if (!HOSTED_CAL_FEATURES) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no_account_exists", - }); + if (!HOSTED_CAL_FEATURES) throw new ErrorWithCode(ErrorCode.Unauthorized, "no_account_exists"); const domain = email.split("@")[1]; const organizationRepository = getOrganizationRepository(); const organization = await organizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain); - if (!organization) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no_account_exists", - }); + if (!organization) throw new ErrorWithCode(ErrorCode.Unauthorized, "no_account_exists"); const createUsersAndConnectToOrgProps = { emailsToCreate: [email], @@ -58,10 +50,7 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { memberships = await getAllAcceptedMemberships({ prisma, email }); if (!memberships || memberships.length === 0) - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no_account_exists", - }); + throw new ErrorWithCode(ErrorCode.Unauthorized, "no_account_exists"); } // Check SSO connections for each team user is a member of @@ -81,11 +70,10 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { .filter((connections) => connections.length > 0); if (connectionsFound.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Could not find a SSO Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal", - }); + throw new ErrorWithCode( + ErrorCode.BadRequest, + "Could not find a SSO Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal" + ); } return { diff --git a/packages/features/ee/teams/services/teamService.test.ts b/packages/features/ee/teams/services/teamService.test.ts index d25aa8c684..7ca83eac6b 100644 --- a/packages/features/ee/teams/services/teamService.test.ts +++ b/packages/features/ee/teams/services/teamService.test.ts @@ -7,11 +7,11 @@ import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepos import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import type { Membership, Team, User, VerificationToken, Profile } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - import { TeamService } from "./teamService"; vi.mock("@calcom/ee/billing/di/containers/Billing"); @@ -77,7 +77,7 @@ describe("TeamService", () => { describe("inviteMemberByToken", () => { it("should throw error if verification token is not found", async () => { prismaMock.verificationToken.findFirst.mockResolvedValue(null); - await expect(TeamService.inviteMemberByToken("invalid-token", 1)).rejects.toThrow(TRPCError); + await expect(TeamService.inviteMemberByToken("invalid-token", 1)).rejects.toThrow(ErrorWithCode); }); it("should create provisional membership and update billing", async () => { @@ -235,7 +235,7 @@ describe("TeamService", () => { describe("acceptInvitationByToken", () => { it("should throw error if verification token is not found", async () => { prismaMock.verificationToken.findFirst.mockResolvedValue(null); - await expect(TeamService.acceptInvitationByToken("invalid-token", 1)).rejects.toThrow(TRPCError); + await expect(TeamService.acceptInvitationByToken("invalid-token", 1)).rejects.toThrow(ErrorWithCode); }); it("should throw error if token is not associated with team", async () => { @@ -251,10 +251,7 @@ describe("TeamService", () => { ); await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( - new TRPCError({ - code: "NOT_FOUND", - message: "Invite token is not associated with any team", - }) + new ErrorWithCode(ErrorCode.NotFound, "Invite token is not associated with any team") ); }); @@ -272,7 +269,7 @@ describe("TeamService", () => { prismaMock.user.findUnique.mockResolvedValue(null); await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( - new TRPCError({ code: "NOT_FOUND", message: "User not found" }) + new ErrorWithCode(ErrorCode.NotFound, "User not found") ); }); @@ -295,10 +292,7 @@ describe("TeamService", () => { prismaMock.user.findUnique.mockResolvedValue(mockUser as User); await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( - new TRPCError({ - code: "FORBIDDEN", - message: "This invitation is not for your account", - }) + new ErrorWithCode(ErrorCode.Forbidden, "This invitation is not for your account") ); }); @@ -321,10 +315,7 @@ describe("TeamService", () => { prismaMock.user.findUnique.mockResolvedValue(mockUser as User); await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( - new TRPCError({ - code: "FORBIDDEN", - message: "This invitation is not for your account", - }) + new ErrorWithCode(ErrorCode.Forbidden, "This invitation is not for your account") ); }); diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index 0a9440ae36..c41bf9b4e7 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -11,13 +11,13 @@ import { ProfileRepository } from "@calcom/features/profile/repositories/Profile import { WEBAPP_URL } from "@calcom/lib/constants"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; import logger from "@calcom/lib/logger"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { prisma } from "@calcom/prisma"; import type { Membership } from "@calcom/prisma/client"; import { Prisma } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - const log = logger.getSubLogger({ prefix: ["TeamService"] }); type MembershipWithRelations = Pick< @@ -62,7 +62,7 @@ export class TeamService { select: { parentId: true, isOrganization: true }, }); - if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + if (!team) throw new ErrorWithCode(ErrorCode.NotFound, "Team not found"); const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization); @@ -74,7 +74,7 @@ export class TeamService { teamId, }, }); - if (!existingToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite token not found" }); + if (!existingToken) throw new ErrorWithCode(ErrorCode.NotFound, "Invite token not found"); return { token: existingToken.token, inviteLink: await TeamService.buildInviteLink( @@ -177,7 +177,6 @@ export class TeamService { await Promise.allSettled(teamBillingPromises); } - // TODO: Move errors away from TRPC error to make it more generic static async inviteMemberByToken(token: string, userId: number) { const verificationToken = await prisma.verificationToken.findFirst({ where: { @@ -194,12 +193,9 @@ export class TeamService { }, }); - if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" }); + if (!verificationToken) throw new ErrorWithCode(ErrorCode.NotFound, "Invite not found"); if (!verificationToken.teamId || !verificationToken.team) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Invite token is not associated with any team", - }); + throw new ErrorWithCode(ErrorCode.NotFound, "Invite token is not associated with any team"); try { await prisma.membership.create({ @@ -214,10 +210,10 @@ export class TeamService { } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); + throw new ErrorWithCode( + ErrorCode.Forbidden, + "This user is a member of this team / has a pending invitation." + ); } } else throw e; } @@ -319,14 +315,11 @@ export class TeamService { }); if (!verificationToken) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" }); + throw new ErrorWithCode(ErrorCode.NotFound, "Invite not found"); } if (!verificationToken.teamId || !verificationToken.team) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Invite token is not associated with any team", - }); + throw new ErrorWithCode(ErrorCode.NotFound, "Invite token is not associated with any team"); } const currentUser = await prisma.user.findUnique({ @@ -335,17 +328,14 @@ export class TeamService { }); if (!currentUser) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + throw new ErrorWithCode(ErrorCode.NotFound, "User not found"); } if ( currentUser.email !== verificationToken.identifier && currentUser.username !== verificationToken.identifier ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This invitation is not for your account", - }); + throw new ErrorWithCode(ErrorCode.Forbidden, "This invitation is not for your account"); } await TeamService.acceptTeamMembership({ @@ -408,7 +398,7 @@ export class TeamService { }); if (!membership) { - throw new TRPCError({ code: "NOT_FOUND", message: "Membership not found" }); + throw new ErrorWithCode(ErrorCode.NotFound, "Membership not found"); } return membership; @@ -429,7 +419,7 @@ export class TeamService { }); if (!team) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + throw new ErrorWithCode(ErrorCode.NotFound, "Team not found"); } return team; @@ -459,7 +449,7 @@ export class TeamService { }); if (!user) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + throw new ErrorWithCode(ErrorCode.NotFound, "User not found"); } return user; diff --git a/packages/features/eslint.config.mjs b/packages/features/eslint.config.mjs new file mode 100644 index 0000000000..90f555f94c --- /dev/null +++ b/packages/features/eslint.config.mjs @@ -0,0 +1,11 @@ +import { forbid } from "@calcom/eslint-config/base"; +import { config } from "@calcom/eslint-config/base"; + +export default [ + ...config, + forbid({ + from: "../trpc/**", + target: ".", + message: "features package should not import from trpc to avoid circular dependencies.", + }), +]; diff --git a/packages/features/eventtypes/lib/getEventTypesByViewer.ts b/packages/features/eventtypes/lib/getEventTypesByViewer.ts index db88ff146b..7ad4096bd2 100644 --- a/packages/features/eventtypes/lib/getEventTypesByViewer.ts +++ b/packages/features/eventtypes/lib/getEventTypesByViewer.ts @@ -9,6 +9,8 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; @@ -18,8 +20,6 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { eventTypeMetaDataSchemaWithUntypedApps } from "@calcom/prisma/zod-utils"; -import { TRPCError } from "@trpc/server"; - const log = logger.getSubLogger({ prefix: ["viewer.eventTypes.getByViewer"] }); type User = { @@ -96,7 +96,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo ]); if (!profile) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + throw new ErrorWithCode(ErrorCode.InternalServerError, "Profile not found"); } const memberships = profileMemberships.map((membership) => ({ diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index e874a9322e..6be695e9d0 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -13,8 +13,8 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils"; import type { Ensure } from "@calcom/types/utils"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); @@ -476,7 +476,7 @@ export class EventTypeRepository { }, }); - if (!teamMembership) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!teamMembership) throw new ErrorWithCode(ErrorCode.Unauthorized, "User is not a member of this team"); return await prisma.eventType.findMany({ where: { diff --git a/packages/features/handleCreatePhoneCall.ts b/packages/features/handleCreatePhoneCall.ts index 3fcb8e99a7..a002b054d8 100644 --- a/packages/features/handleCreatePhoneCall.ts +++ b/packages/features/handleCreatePhoneCall.ts @@ -5,8 +5,8 @@ import { validatePhoneNumber } from "@calcom/features/calAIPhone/retellAIService import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; - -import { TRPCError } from "@trpc/server"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; export const handleCreatePhoneCall = async ({ user, @@ -16,7 +16,7 @@ export const handleCreatePhoneCall = async ({ input: TCreatePhoneCallSchema; }) => { if (!user?.profile?.organization) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not part of an organization" }); + throw new ErrorWithCode(ErrorCode.Unauthorized, "User is not part of an organization"); } await checkRateLimitAndThrowError({ diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 901320d002..d6f44ec92e 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -1,4 +1,12 @@ export enum ErrorCode { + // Generic HTTP error codes for transport-agnostic error handling + Unauthorized = "unauthorized_error", + Forbidden = "forbidden_error", + NotFound = "not_found_error", + BadRequest = "bad_request_error", + InternalServerError = "internal_server_error", + + // Domain-specific error codes PaymentCreationFailure = "payment_not_created_error", NoAvailableUsersFound = "no_available_users_found_error", ChargeCardFailure = "couldnt_charge_card_error", diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts index 32cdcebfa7..197e7bd076 100644 --- a/packages/lib/server/getServerErrorFromUnknown.ts +++ b/packages/lib/server/getServerErrorFromUnknown.ts @@ -61,7 +61,7 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError { return getHttpError({ statusCode: 400, cause: parsedStripeError.data }); } if (cause instanceof ErrorWithCode) { - const statusCode = getStatusCode(cause); + const statusCode = getHttpStatusCode(cause); return new HttpError({ statusCode, message: cause.message ?? "", @@ -82,7 +82,7 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError { }; } if (cause instanceof Error) { - const statusCode = getStatusCode(cause); + const statusCode = getHttpStatusCode(cause); return getHttpError({ statusCode, cause }); } if (typeof cause === "string") { @@ -98,10 +98,23 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError { }); } -function getStatusCode(cause: Error | ErrorWithCode): number { +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: diff --git a/packages/trpc/server/lib/toTRPCError.test.ts b/packages/trpc/server/lib/toTRPCError.test.ts new file mode 100644 index 0000000000..a24abc7a42 --- /dev/null +++ b/packages/trpc/server/lib/toTRPCError.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { TRPCError } from "@trpc/server"; + +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; + +import { convertErrorWithCodeToTRPCError } from "./toTRPCError"; + +describe("convertErrorWithCodeToTRPCError", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("converts ErrorWithCode with Forbidden code to TRPCError with FORBIDDEN", () => { + const error = new ErrorWithCode(ErrorCode.Forbidden, "This invitation is not for your account"); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBeInstanceOf(TRPCError); + expect((result as TRPCError).code).toBe("FORBIDDEN"); + expect((result as TRPCError).message).toBe("This invitation is not for your account"); + }); + + it("converts ErrorWithCode with NotFound code to TRPCError with NOT_FOUND", () => { + const error = new ErrorWithCode(ErrorCode.NotFound, "User not found"); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBeInstanceOf(TRPCError); + expect((result as TRPCError).code).toBe("NOT_FOUND"); + expect((result as TRPCError).message).toBe("User not found"); + }); + + it("converts ErrorWithCode with Unauthorized code to TRPCError with UNAUTHORIZED", () => { + const error = new ErrorWithCode(ErrorCode.Unauthorized, "Not authenticated"); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBeInstanceOf(TRPCError); + expect((result as TRPCError).code).toBe("UNAUTHORIZED"); + expect((result as TRPCError).message).toBe("Not authenticated"); + }); + + it("converts ErrorWithCode with BadRequest code to TRPCError with BAD_REQUEST", () => { + const error = new ErrorWithCode(ErrorCode.BadRequest, "Invalid input"); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBeInstanceOf(TRPCError); + expect((result as TRPCError).code).toBe("BAD_REQUEST"); + expect((result as TRPCError).message).toBe("Invalid input"); + }); + + it("returns generic Error unchanged", () => { + const error = new Error("Something went wrong"); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBe(error); + expect(result).not.toBeInstanceOf(TRPCError); + }); + + it("returns TRPCError unchanged", () => { + const error = new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBe(error); + }); + + it("returns string error unchanged", () => { + const error = "Something went wrong"; + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBe(error); + }); + + it("returns unknown error type unchanged", () => { + const error = { unexpected: "object" }; + + const result = convertErrorWithCodeToTRPCError(error); + + expect(result).toBe(error); + }); +}); diff --git a/packages/trpc/server/lib/toTRPCError.ts b/packages/trpc/server/lib/toTRPCError.ts new file mode 100644 index 0000000000..d83839dcca --- /dev/null +++ b/packages/trpc/server/lib/toTRPCError.ts @@ -0,0 +1,73 @@ +import { ErrorWithCode } from "@calcom/lib/errors"; +import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; + +import { TRPCError } from "@trpc/server"; + +// Copied from `TRPC_ERROR_CODE_KEY` from `@trpc/server/unstable-core-do-not-import` +type TRPCErrorCode = + | "PARSE_ERROR" + | "BAD_REQUEST" + | "INTERNAL_SERVER_ERROR" + | "NOT_IMPLEMENTED" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "METHOD_NOT_SUPPORTED" + | "TIMEOUT" + | "CONFLICT" + | "PRECONDITION_FAILED" + | "PAYLOAD_TOO_LARGE" + | "UNPROCESSABLE_CONTENT" + | "TOO_MANY_REQUESTS" + | "CLIENT_CLOSED_REQUEST"; + +function httpStatusToTrpcCode(status: number): TRPCErrorCode { + switch (status) { + case 400: + return "BAD_REQUEST"; + case 401: + return "UNAUTHORIZED"; + case 403: + return "FORBIDDEN"; + case 404: + return "NOT_FOUND"; + case 405: + return "METHOD_NOT_SUPPORTED"; + case 408: + return "TIMEOUT"; + case 409: + return "CONFLICT"; + case 412: + return "PRECONDITION_FAILED"; + case 413: + return "PAYLOAD_TOO_LARGE"; + case 422: + return "UNPROCESSABLE_CONTENT"; + case 429: + return "TOO_MANY_REQUESTS"; + case 499: + return "CLIENT_CLOSED_REQUEST"; + case 501: + return "NOT_IMPLEMENTED"; + default: + return "INTERNAL_SERVER_ERROR"; + } +} + +/** + * Converts only ErrorWithCode instances to TRPC errors to limit impact. + * + * TODO: Consider converting any unknown error into TRPC error in the future. + */ +export function convertErrorWithCodeToTRPCError(cause: unknown) { + if (cause instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(cause); + return new TRPCError({ + code: httpStatusToTrpcCode(statusCode), + message: cause.message ?? "", + cause: cause, + }); + } + + return cause; +} diff --git a/packages/trpc/server/middlewares/errorConversionMiddleware.ts b/packages/trpc/server/middlewares/errorConversionMiddleware.ts new file mode 100644 index 0000000000..09abb7e760 --- /dev/null +++ b/packages/trpc/server/middlewares/errorConversionMiddleware.ts @@ -0,0 +1,13 @@ +import { convertErrorWithCodeToTRPCError } from "../lib/toTRPCError"; +import { middleware } from "../trpc"; + +/** + * Middleware that catches errors thrown by other layers and converts them to TRPCError. + */ +export const errorConversionMiddleware = middleware(async ({ next }) => { + try { + return await next(); + } catch (e) { + throw convertErrorWithCodeToTRPCError(e); + } +}); diff --git a/packages/trpc/server/procedures/authedProcedure.ts b/packages/trpc/server/procedures/authedProcedure.ts index 78845080e6..662cdc94dd 100644 --- a/packages/trpc/server/procedures/authedProcedure.ts +++ b/packages/trpc/server/procedures/authedProcedure.ts @@ -1,3 +1,4 @@ +import { errorConversionMiddleware } from "../middlewares/errorConversionMiddleware"; import perfMiddleware from "../middlewares/perfMiddleware"; import { isAdminMiddleware, isAuthed, isOrgAdminMiddleware } from "../middlewares/sessionMiddleware"; import { procedure } from "../trpc"; @@ -23,7 +24,7 @@ const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOpti return next({ ctx: { user: ctx.user, session: ctx.session } }); }); */ -const authedProcedure = procedure.use(perfMiddleware).use(isAuthed); +const authedProcedure = procedure.use(perfMiddleware).use(errorConversionMiddleware).use(isAuthed); /*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) => authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/ export const authedAdminProcedure = publicProcedure.use(isAdminMiddleware); diff --git a/packages/trpc/server/procedures/publicProcedure.ts b/packages/trpc/server/procedures/publicProcedure.ts index 1b27a25d47..eb8f8386cb 100644 --- a/packages/trpc/server/procedures/publicProcedure.ts +++ b/packages/trpc/server/procedures/publicProcedure.ts @@ -1,6 +1,7 @@ +import { errorConversionMiddleware } from "../middlewares/errorConversionMiddleware"; import perfMiddleware from "../middlewares/perfMiddleware"; import { tRPCContext } from "../trpc"; -const publicProcedure = tRPCContext.procedure.use(perfMiddleware); +const publicProcedure = tRPCContext.procedure.use(perfMiddleware).use(errorConversionMiddleware); export default publicProcedure;