From 3bf2a8fa6c6725752671243faab1373dac223535 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Tue, 2 Dec 2025 20:11:17 +0900 Subject: [PATCH] refactor: replace TRPCError with ErrorWithCode in `packages/features` (#25482) * refactor: replace TRPCError with ErrorWithCode in packages/features This refactor moves error handling from throwing TRPCError directly in packages/features to throwing ErrorWithCode instead. The conversion to TRPCError now happens at the TRPC layer. Changes: - Add generic ErrorCode values (Unauthorized, Forbidden, NotFound, BadRequest, InternalServerError) to errorCodes.ts - Update getServerErrorFromUnknown to map new ErrorCodes to proper HTTP status codes - Create toTRPCError helper in packages/trpc/server/lib - Create errorMappingMiddleware in packages/trpc/server/middlewares - Migrate TRPCError throws in packages/features to ErrorWithCode: - teamService.ts - getEventTypeById.ts - eventTypeRepository.ts - OrganizationPermissionService.ts - OrganizationPaymentService.ts - sso.ts - handleCreatePhoneCall.ts - userCanCreateTeamGroupMapping.ts This improves separation of concerns by making packages/features transport-agnostic, allowing the same feature code to be reused from tRPC, API routes, workers, etc. Co-Authored-By: benny@cal.com * fix: remove isTrpcCall parameter and fix lint warning - Remove isTrpcCall parameter from get.handler.ts call since the feature layer no longer needs to know about tRPC - Fix unsafe optional chaining lint warning in getEventTypesByViewer.ts by precomputing usersSource variable - Complete migration of getEventTypesByViewer.ts to ErrorWithCode Co-Authored-By: benny@cal.com * revert * add eslint rule * add comment * fix: add isTrpcCall back to getEventTypeById interface The user reverted the removal of isTrpcCall parameter from the handler, so we need to add it back to the interface to fix the type error. Co-Authored-By: benny@cal.com * test: update teamService tests to expect ErrorWithCode instead of TRPCError Co-Authored-By: benny@cal.com * refactor * wip * feat: integrate errorMappingMiddleware into base TRPC procedure Co-Authored-By: benny@cal.com * connect middlewares * revert * revert * refactor * rename * fix: handle ErrorWithCode in teams server-page error handling The error handling was checking for TRPCError, but teamService now throws ErrorWithCode. This caused the 'This invitation is not for your account' error message to not be displayed when a wrong user tries to use an invitation link. Co-Authored-By: benny@cal.com * fix * fix * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../(main-nav)/teams/server-page.tsx | 5 +- .../server/userCanCreateTeamGroupMapping.ts | 19 ++-- .../lib/OrganizationPaymentService.ts | 12 +-- .../lib/OrganizationPermissionService.ts | 30 +++---- packages/features/ee/sso/lib/sso.ts | 30 ++----- .../ee/teams/services/teamService.test.ts | 25 ++---- .../features/ee/teams/services/teamService.ts | 44 ++++------ packages/features/eslint.config.mjs | 11 +++ .../eventtypes/lib/getEventTypesByViewer.ts | 6 +- .../repositories/eventTypeRepository.ts | 6 +- packages/features/handleCreatePhoneCall.ts | 6 +- packages/lib/errorCodes.ts | 8 ++ .../lib/server/getServerErrorFromUnknown.ts | 19 +++- packages/trpc/server/lib/toTRPCError.test.ts | 87 +++++++++++++++++++ packages/trpc/server/lib/toTRPCError.ts | 73 ++++++++++++++++ .../middlewares/errorConversionMiddleware.ts | 13 +++ .../trpc/server/procedures/authedProcedure.ts | 3 +- .../trpc/server/procedures/publicProcedure.ts | 3 +- 18 files changed, 280 insertions(+), 120 deletions(-) create mode 100644 packages/features/eslint.config.mjs create mode 100644 packages/trpc/server/lib/toTRPCError.test.ts create mode 100644 packages/trpc/server/lib/toTRPCError.ts create mode 100644 packages/trpc/server/middlewares/errorConversionMiddleware.ts 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;