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 <sldisek783@gmail.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 <sldisek783@gmail.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 <sldisek783@gmail.com>

* test: update teamService tests to expect ErrorWithCode instead of TRPCError

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor

* wip

* feat: integrate errorMappingMiddleware into base TRPC procedure

Co-Authored-By: benny@cal.com <sldisek783@gmail.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 <sldisek783@gmail.com>

* fix

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Benny Joo
2025-12-02 08:11:17 -03:00
committed by GitHub
co-authored by Devin AI
parent 16a4c75e1e
commit 3bf2a8fa6c
18 changed files with 280 additions and 120 deletions
@@ -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;
}
}
@@ -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<TrpcSessionUser>,
@@ -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");
}
}
@@ -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);
@@ -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<boolean> {
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;
+9 -21
View File
@@ -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 {
@@ -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")
);
});
@@ -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;
+11
View File
@@ -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.",
}),
];
@@ -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) => ({
@@ -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: {
+3 -3
View File
@@ -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({
+8
View File
@@ -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",
@@ -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:
@@ -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);
});
});
+73
View File
@@ -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;
}
@@ -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);
}
});
@@ -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);
@@ -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;