feat: add initial api logging (#1494)

Improve API logging and error handling between client and server side.
This commit is contained in:
David Nguyen
2024-11-28 16:05:37 +07:00
committed by GitHub
parent 04293968c6
commit 98d85b086d
53 changed files with 933 additions and 780 deletions

View File

@ -1,4 +1,4 @@
import { TRPCError } from '@trpc/server';
import type { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod';
@ -8,46 +8,69 @@ import { TRPCClientError } from '@documenso/trpc/client';
* Generic application error codes.
*/
export enum AppErrorCode {
'ALREADY_EXISTS' = 'AlreadyExists',
'EXPIRED_CODE' = 'ExpiredCode',
'INVALID_BODY' = 'InvalidBody',
'INVALID_REQUEST' = 'InvalidRequest',
'LIMIT_EXCEEDED' = 'LimitExceeded',
'NOT_FOUND' = 'NotFound',
'NOT_SETUP' = 'NotSetup',
'UNAUTHORIZED' = 'Unauthorized',
'UNKNOWN_ERROR' = 'UnknownError',
'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests',
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
'ALREADY_EXISTS' = 'ALREADY_EXISTS',
'EXPIRED_CODE' = 'EXPIRED_CODE',
'INVALID_BODY' = 'INVALID_BODY',
'INVALID_REQUEST' = 'INVALID_REQUEST',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'PROFILE_URL_TAKEN' = 'PROFILE_URL_TAKEN',
'PREMIUM_PROFILE_URL' = 'PREMIUM_PROFILE_URL',
}
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
[AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
[AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
[AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
[AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
[AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
[AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
[AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
[AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
export const genericErrorCodeToTrpcErrorCodeMap: Record<
string,
{ code: TRPCError['code']; status: number }
> = {
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.PROFILE_URL_TAKEN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.PREMIUM_PROFILE_URL]: { code: 'BAD_REQUEST', status: 400 },
};
export const ZAppErrorJsonSchema = z.object({
code: z.string(),
message: z.string().optional(),
userMessage: z.string().optional(),
statusCode: z.number().optional(),
});
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
type AppErrorOptions = {
/**
* An internal message for logging.
*/
message?: string;
/**
* A message which can be potientially displayed to the user.
*/
userMessage?: string;
/**
* The status code to be associated with the error.
*
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
};
export class AppError extends Error {
/**
* The error code.
@ -59,6 +82,11 @@ export class AppError extends Error {
*/
userMessage?: string;
/**
* The status code to be associated with the error.
*/
statusCode?: number;
/**
* Create a new AppError.
*
@ -66,10 +94,12 @@ export class AppError extends Error {
* @param message An internal error message.
* @param userMessage A error message which can be displayed to the user.
*/
public constructor(errorCode: string, message?: string, userMessage?: string) {
super(message || errorCode);
public constructor(errorCode: string, options?: AppErrorOptions) {
super(options?.message || errorCode);
this.code = errorCode;
this.userMessage = userMessage;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
}
/**
@ -84,16 +114,21 @@ export class AppError extends Error {
// Handle TRPC errors.
if (error instanceof TRPCClientError) {
const parsedJsonError = AppError.parseFromJSONString(error.message);
return parsedJsonError || new AppError('UnknownError', error.message);
const parsedJsonError = AppError.parseFromJSON(error.data?.appError);
const fallbackError = new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: error.message,
});
return parsedJsonError || fallbackError;
}
// Handle completely unknown errors.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { code, message, userMessage } = error as {
const { code, message, userMessage, statusCode } = error as {
code: unknown;
message: unknown;
status: unknown;
statusCode: unknown;
userMessage: unknown;
};
@ -102,16 +137,15 @@ export class AppError extends Error {
const validUserMessage: string | undefined =
typeof userMessage === 'string' ? userMessage : undefined;
return new AppError(validCode, validMessage, validUserMessage);
}
const validStatusCode = typeof statusCode === 'number' ? statusCode : undefined;
static parseErrorToTRPCError(error: unknown): TRPCError {
const appError = AppError.parseError(error);
const options: AppErrorOptions = {
message: validMessage,
userMessage: validUserMessage,
statusCode: validStatusCode,
};
return new TRPCError({
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
message: AppError.toJSONString(appError),
});
return new AppError(validCode, options);
}
/**
@ -120,12 +154,26 @@ export class AppError extends Error {
* @param appError The AppError to convert to JSON.
* @returns A JSON object representing the AppError.
*/
static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
return {
static toJSON({ code, message, userMessage, statusCode }: AppError): TAppErrorJsonSchema {
const data: TAppErrorJsonSchema = {
code,
message,
userMessage,
};
// Explicity only set values if it exists, since TRPC will add meta for undefined
// values which clutters up API responses.
if (message) {
data.message = message;
}
if (userMessage) {
data.userMessage = userMessage;
}
if (statusCode) {
data.statusCode = statusCode;
}
return data;
}
/**
@ -138,15 +186,21 @@ export class AppError extends Error {
return JSON.stringify(AppError.toJSON(appError));
}
static parseFromJSONString(jsonString: string): AppError | null {
static parseFromJSON(value: unknown): AppError | null {
try {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
const parsed = ZAppErrorJsonSchema.safeParse(value);
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
const { message, userMessage, statusCode } = parsed.data;
return new AppError(parsed.data.code, {
message,
userMessage,
statusCode,
});
} catch {
return null;
}

View File

@ -25,6 +25,7 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@honeybadger-io/js": "^6.10.1",
"@lingui/core": "^4.11.3",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
@ -62,4 +63,4 @@
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -40,7 +40,9 @@ export const createPasskeyAuthenticationOptions = async ({
});
if (!preferredPasskey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Requested passkey not found',
});
}
}

View File

@ -50,7 +50,9 @@ export const createPasskey = async ({
});
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Challenge token not found',
});
}
await prisma.verificationToken.deleteMany({
@ -61,7 +63,9 @@ export const createPasskey = async ({
});
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'Challenge token expired',
});
}
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
@ -74,7 +78,9 @@ export const createPasskey = async ({
});
if (!verification.verified || !verification.registrationInfo) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Verification failed',
});
}
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =

View File

@ -47,7 +47,9 @@ export const createDocument = async ({
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;

View File

@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
@ -20,7 +21,7 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
teamId,
});
return await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
documentData: true,
@ -45,6 +46,14 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
}
return document;
};
export type GetDocumentWhereInputOptions = {

View File

@ -107,7 +107,9 @@ export const getDocumentAndSenderByToken = async ({
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
return {
@ -167,7 +169,9 @@ export const getDocumentAndRecipientByToken = async ({
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
return {

View File

@ -106,7 +106,9 @@ export const isRecipientAuthorized = async ({
// Should not be possible.
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
return await verifyTwoFactorAuthenticationToken({
@ -164,7 +166,9 @@ const verifyPasskey = async ({
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Passkey not found',
});
}
const verificationToken = await prisma.verificationToken
@ -177,11 +181,15 @@ const verifyPasskey = async ({
.catch(() => null);
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Token not found',
});
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'Token expired',
});
}
const { rpId, origin } = getAuthenticatorOptions();
@ -199,7 +207,9 @@ const verifyPasskey = async ({
}).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is not authorized',
});
}
await prisma.passkey.update({

View File

@ -37,7 +37,9 @@ export const updateDocumentSettings = async ({
requestMetadata,
}: UpdateDocumentSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Missing data to update',
});
}
const user = await prisma.user.findFirstOrThrow({
@ -96,10 +98,9 @@ export const updateDocumentSettings = async ({
!allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility))
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
@ -107,17 +108,15 @@ export const updateDocumentSettings = async ({
document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
});
});
}
@ -142,10 +141,9 @@ export const updateDocumentSettings = async ({
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
@ -161,10 +159,9 @@ export const updateDocumentSettings = async ({
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'You cannot update the title if the document has been sent',
);
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the document has been sent',
});
}
if (!isTitleSame) {

View File

@ -45,7 +45,9 @@ export const validateFieldAuth = async ({
});
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid authentication values',
});
}
return derivedRecipientActionAuth;

View File

@ -104,7 +104,9 @@ export const setFieldsForDocument = async ({
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient not found for field ${field.id}`,
});
}
// Check whether the existing field can be modified.
@ -113,10 +115,10 @@ export const setFieldsForDocument = async ({
hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a field where the recipient has already interacted with the document',
);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {

View File

@ -115,7 +115,9 @@ export const getPublicProfileByUrl = async ({
// Log as critical error.
if (user?.profile && team?.profile) {
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Profile URL is ambiguous',
});
}
if (user?.profile?.enabled) {
@ -177,5 +179,7 @@ export const getPublicProfileByUrl = async ({
};
}
throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Profile not found',
});
};

View File

@ -18,10 +18,9 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have the required permissions to view this page.',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have the required permissions to view this page.',
});
}
return await prisma.apiToken.findMany({

View File

@ -105,10 +105,9 @@ export const setRecipientsForDocument = async ({
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
@ -142,10 +141,9 @@ export const setRecipientsForDocument = async ({
hasRecipientBeenChanged(existing, recipient) &&
!canRecipientBeModified(existing, document.Field)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a recipient who has already interacted with the document',
);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
}
return {

View File

@ -72,10 +72,9 @@ export const setRecipientsForTemplate = async ({
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
@ -119,14 +118,15 @@ export const setRecipientsForTemplate = async ({
);
if (updatedDirectRecipient?.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC');
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Cannot set direct recipient as CC',
});
}
if (deletedDirectRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'Cannot delete direct recipient while direct template exists',
);
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Cannot delete direct recipient while direct template exists',
});
}
}

View File

@ -96,10 +96,9 @@ export const updateRecipient = async ({
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}

View File

@ -47,6 +47,8 @@ export const createTeamPendingCheckoutSession = async ({
console.error(e);
// Absorb all the errors incase Stripe throws something sensitive.
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.');
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Something went wrong.',
});
}
};

View File

@ -55,10 +55,9 @@ export const createTeamEmailVerification = async ({
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
const existingTeamEmail = await tx.teamEmail.findFirst({
@ -68,7 +67,9 @@ export const createTeamEmailVerification = async ({
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
@ -97,7 +98,9 @@ export const createTeamEmailVerification = async ({
const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('email')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
throw err;

View File

@ -69,7 +69,9 @@ export const createTeamMemberInvites = async ({
const currentTeamMember = team.members.find((member) => member.user.id === userId);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not part of team.',
});
}
const usersToInvite = invitations.filter((invitation) => {
@ -91,10 +93,9 @@ export const createTeamMemberInvites = async ({
);
if (unauthorizedRoleAccess) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'User does not have permission to set high level roles',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User does not have permission to set high level roles',
});
}
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
@ -127,11 +128,10 @@ export const createTeamMemberInvites = async ({
if (sendEmailResultErrorList.length > 0) {
console.error(JSON.stringify(sendEmailResultErrorList));
throw new AppError(
'EmailDeliveryFailed',
'Failed to send invite emails to one or more users.',
`Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
);
throw new AppError('EmailDeliveryFailed', {
message: 'Failed to send invite emails to one or more users.',
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
});
}
};

View File

@ -87,7 +87,9 @@ export const createTeam = async ({
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
});
}
await tx.team.create({
@ -131,15 +133,21 @@ export const createTeam = async ({
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
});
}
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
if (!customerId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.');
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing customer ID for pending teams.',
});
}
return await tx.teamPending.create({
@ -166,7 +174,9 @@ export const createTeam = async ({
const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
throw err;

View File

@ -60,11 +60,13 @@ export const deleteTeamMembers = async ({
);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team member record does not exist',
});
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot remove the team owner' });
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
@ -72,7 +74,9 @@ export const deleteTeamMembers = async ({
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot remove a member with a higher role',
});
}
// Remove the team members.

View File

@ -24,7 +24,9 @@ export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptio
});
if (!team.customerId) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team has no customer ID.',
});
}
const results = await getInvoices({ customerId: team.customerId });

View File

@ -33,7 +33,9 @@ export const getTeamPublicProfile = async ({
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
// Create and return the public profile.
@ -47,7 +49,9 @@ export const getTeamPublicProfile = async ({
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Failed to create public profile',
});
}
return {

View File

@ -38,16 +38,17 @@ export const resendTeamEmailVerification = async ({
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
throw new AppError('TeamNotFound', {
message: 'User is not a member of the team.',
});
}
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
throw new AppError('VerificationNotFound', {
message: 'No team email verification exists for this team.',
});
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });

View File

@ -55,7 +55,7 @@ export const resendTeamMemberInvitation = async ({
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
throw new AppError('TeamNotFound', { message: 'User is not a valid member of the team.' });
}
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
@ -66,7 +66,7 @@ export const resendTeamMemberInvitation = async ({
});
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
throw new AppError('InviteNotFound', { message: 'No invite exists for this user.' });
}
await sendTeamMemberInviteEmail({

View File

@ -48,11 +48,11 @@ export const updateTeamMember = async ({
const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId);
if (!teamMemberToUpdate || !currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member does not exist');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Team member does not exist' });
}
if (teamMemberToUpdate.userId === team.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner');
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot update the owner' });
}
const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy(
@ -61,7 +61,9 @@ export const updateTeamMember = async ({
);
if (isMemberToUpdateHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot update a member with a higher role',
});
}
const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy(
@ -70,10 +72,9 @@ export const updateTeamMember = async ({
);
if (isNewMemberRoleHigherThanCurrentRole) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'Cannot give a member a role higher than the user initating the update',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot give a member a role higher than the user initating the update',
});
}
return await tx.teamMember.update({

View File

@ -24,7 +24,9 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
});
if (foundPendingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
const team = await tx.team.update({
@ -57,7 +59,9 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
throw err;

View File

@ -101,7 +101,7 @@ export const createDocumentFromDirectTemplate = async ({
});
if (!template?.directLink?.enabled) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing template');
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
const { Recipient: recipients, directLink, User: templateOwner } = template;
@ -111,15 +111,19 @@ export const createDocumentFromDirectTemplate = async ({
);
if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing direct recipient');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing direct recipient',
});
}
if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Template no longer matches');
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
if (user && user.email !== directRecipientEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Email must match if you are logged in');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Email must match if you are logged in',
});
}
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
@ -136,7 +140,7 @@ export const createDocumentFromDirectTemplate = async ({
.exhaustive();
if (!isAccessAuthValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'You must be logged in');
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'You must be logged in' });
}
const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse(
@ -163,7 +167,9 @@ export const createDocumentFromDirectTemplate = async ({
);
if (!signedFieldValue) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid, missing or changed fields');
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid, missing or changed fields',
});
}
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {

View File

@ -120,7 +120,9 @@ export const createDocumentFromTemplate = async ({
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
// Check that all the passed in recipient IDs can be associated with a template recipient.
@ -130,10 +132,9 @@ export const createDocumentFromTemplate = async ({
);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Recipient with ID ${recipient.id} not found in the template.`,
);
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Recipient with ID ${recipient.id} not found in the template.`,
});
}
});

View File

@ -47,18 +47,18 @@ export const createTemplateDirectLink = async ({
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Template not found' });
}
if (template.directLink) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Direct template already exists');
throw new AppError(AppErrorCode.ALREADY_EXISTS, { message: 'Direct template already exists' });
}
if (
directRecipientId &&
!template.Recipient.find((recipient) => recipient.id === directRecipientId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Recipient not found');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Recipient not found' });
}
if (
@ -67,7 +67,9 @@ export const createTemplateDirectLink = async ({
(recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
)
) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot generate placeholder direct recipient');
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Cannot generate placeholder direct recipient',
});
}
return await prisma.$transaction(async (tx) => {

View File

@ -39,7 +39,9 @@ export const deleteTemplateDirectLink = async ({
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const { directLink } = template;

View File

@ -53,7 +53,9 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
return template;

View File

@ -1,6 +1,8 @@
import { prisma } from '@documenso/prisma';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type GetTemplateWithDetailsByIdOptions = {
id: number;
userId: number;
@ -10,7 +12,7 @@ export const getTemplateWithDetailsById = async ({
id,
userId,
}: GetTemplateWithDetailsByIdOptions): Promise<TemplateWithDetails> => {
return await prisma.template.findFirstOrThrow({
const template = await prisma.template.findFirst({
where: {
id,
OR: [
@ -36,4 +38,12 @@ export const getTemplateWithDetailsById = async ({
Field: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
return template;
};

View File

@ -40,13 +40,17 @@ export const toggleTemplateDirectLink = async ({
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const { directLink } = template;
if (!directLink) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Direct template link not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Direct template link not found',
});
}
return await prisma.templateDirectLink.update({

View File

@ -34,7 +34,9 @@ export const updateTemplateSettings = async ({
data,
}: UpdateTemplateSettingsOptions) => {
if (Object.values(data).length === 0 && Object.keys(meta ?? {}).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Missing data to update',
});
}
const template = await prisma.template.findFirstOrThrow({
@ -82,10 +84,9 @@ export const updateTemplateSettings = async ({
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}

View File

@ -38,11 +38,10 @@ export const createUser = async ({ name, email, password, signature, url }: Crea
});
if (urlExists) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
throw new AppError(AppErrorCode.PROFILE_URL_TAKEN, {
message: 'Profile username is taken',
userMessage: 'The profile username is already taken',
});
}
}

View File

@ -26,7 +26,7 @@ export const getUserPublicProfile = async ({
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
// Create and return the public profile.
@ -39,7 +39,7 @@ export const getUserPublicProfile = async ({
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Failed to create public profile' });
}
return {

View File

@ -13,7 +13,7 @@ export type UpdatePublicProfileOptions = {
export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileOptions) => {
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
throw new AppError(AppErrorCode.INVALID_BODY, { message: 'Missing data to update' });
}
const { url, bio, enabled } = data;
@ -25,13 +25,15 @@ export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileO
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
const finalUrl = url ?? user.url;
if (!finalUrl && enabled) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Cannot enable a profile without a URL');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot enable a profile without a URL',
});
}
if (url) {
@ -57,7 +59,9 @@ export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileO
});
if (isUrlTakenByAnotherUser || isUrlTakenByAnotherTeam) {
throw new AppError(AppErrorCode.PROFILE_URL_TAKEN, 'The profile username is already taken');
throw new AppError(AppErrorCode.PROFILE_URL_TAKEN, {
message: 'The profile username is already taken',
});
}
}

View File

@ -0,0 +1,108 @@
import Honeybadger from '@honeybadger-io/js';
export const buildLogger = () => {
if (process.env.NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY) {
return new HoneybadgerLogger();
}
return new DefaultLogger();
};
interface LoggerDescriptionOptions {
method?: string;
path?: string;
context?: Record<string, unknown>;
/**
* The type of log to be captured.
*
* Defaults to `info`.
*/
level?: 'info' | 'error' | 'critical';
}
/**
* Basic logger implementation intended to be used in the server side for capturing
* explicit errors and other logs.
*
* Not intended to capture the request and responses.
*/
interface Logger {
log(message: string, options?: LoggerDescriptionOptions): void;
error(error: Error, options?: LoggerDescriptionOptions): void;
}
class DefaultLogger implements Logger {
log(_message: string, _options?: LoggerDescriptionOptions) {
// Do nothing.
}
error(_error: Error, _options?: LoggerDescriptionOptions): void {
// Do nothing.
}
}
class HoneybadgerLogger implements Logger {
constructor() {
if (!process.env.NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY) {
throw new Error('NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY is not set');
}
Honeybadger.configure({
apiKey: process.env.NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY,
});
}
/**
* Honeybadger doesn't really have a non-error logging system.
*/
log(message: string, options?: LoggerDescriptionOptions) {
const { context = {}, level = 'info' } = options || {};
try {
Honeybadger.event({
message,
context: {
level,
...context,
},
});
} catch (err) {
console.error(err);
// Do nothing.
}
}
error(error: Error, options?: LoggerDescriptionOptions): void {
const { context = {}, level = 'error', method, path } = options || {};
const tags = [`level:${level}`];
let errorMessage = error.message;
if (method) {
tags.push(`method:${method}`);
errorMessage = `[${method}]: ${error.message}`;
}
if (path) {
tags.push(`path:${path}`);
}
try {
Honeybadger.notify(errorMessage, {
context: {
level,
...context,
},
tags,
});
} catch (err) {
console.error(err);
// Do nothing.
}
}
}