Improve userFriendlyMessage devX (#16815)
Two challenges with error messages - always provide a useful/meaningful error message for the end user instead of the generic one. eg: show "Wrong password" and not "An error occured" - avoid technical details unless error regards a technical feature. eg: show "An error occured" and not "Invalid post-hook payload."; but do show "Invalid issuer URL." as it occurs while configuring SSO What this PR does - Make userFriendlyMessage mandatory for widely used GraphqlQueryRunnerException and CommonQueryRunnerException, so that developers are forced to ask themselves what the error message should be, and as it contains very wide error codes (eg: "Bad request") which should not be mapped to just one default message - Keep userFriendlyMessage optional for service-specific exceptions (eg: workflowStepExecutorException), but convert the error code to userFriendlyMessage mapper to a switch case function with a typecheck ensuring that all codes are mapped to a message. These default messages are still overridable where they are thrown.
This commit is contained in:
+2
-29
@@ -1,5 +1,4 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@@ -24,40 +23,14 @@ export enum GraphqlQueryRunnerExceptionCode {
|
||||
UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED',
|
||||
}
|
||||
|
||||
const graphqlQueryRunnerExceptionUserFriendlyMessages: Record<
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
MessageDescriptor
|
||||
> = {
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT]: msg`Invalid query input.`,
|
||||
[GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED]: msg`Maximum query depth reached.`,
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_CURSOR]: msg`Invalid cursor provided.`,
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION]: msg`Invalid direction provided.`,
|
||||
[GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR]: msg`Unsupported operator.`,
|
||||
[GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT]: msg`Conflicting arguments provided.`,
|
||||
[GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND]: msg`Field not found.`,
|
||||
[GraphqlQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD]: msg`Missing required system field.`,
|
||||
[GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND]: msg`Object not found.`,
|
||||
[GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND]: msg`Record not found.`,
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST]: msg`Invalid 'first' argument.`,
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST]: msg`Invalid 'last' argument.`,
|
||||
[GraphqlQueryRunnerExceptionCode.RELATION_SETTINGS_NOT_FOUND]: msg`Relation settings not found.`,
|
||||
[GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND]: msg`Relation target not found.`,
|
||||
[GraphqlQueryRunnerExceptionCode.NOT_IMPLEMENTED]: msg`This feature is not implemented.`,
|
||||
[GraphqlQueryRunnerExceptionCode.INVALID_POST_HOOK_PAYLOAD]: msg`Invalid post-hook payload.`,
|
||||
[GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT]: msg`Multiple matching records found during upsert.`,
|
||||
[GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED]: msg`Maximum records exceeded for upsert.`,
|
||||
};
|
||||
|
||||
export class GraphqlQueryRunnerException extends CustomException<GraphqlQueryRunnerExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: GraphqlQueryRunnerExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
{ userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor },
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
graphqlQueryRunnerExceptionUserFriendlyMessages[code],
|
||||
userFriendlyMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { compositeTypeDefinitions } from 'twenty-shared/types';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { type WhereExpressionBuilder } from 'typeorm';
|
||||
@@ -73,6 +74,7 @@ export class GraphqlQueryFilterFieldParser {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid filter value for field ${key}. Expected non-empty array`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: msg`Invalid filter value: "${value}"` },
|
||||
);
|
||||
}
|
||||
const { sql, params } = computeWhereConditionParts({
|
||||
@@ -133,6 +135,7 @@ export class GraphqlQueryFilterFieldParser {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid filter value for field ${subFieldKey}. Expected non-empty array`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: msg`Invalid filter value: "${value}"` },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -90,6 +91,7 @@ export class GraphqlQueryOrderFieldParser {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Field "${fieldName}" does not exist or is not sortable`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -35,6 +36,7 @@ export const convertOrderByToFindOptionsOrder = (
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid direction: ${direction}`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
|
||||
@@ -44,6 +45,7 @@ export const getGroupByExpression = ({
|
||||
throw new CommonQueryRunnerException(
|
||||
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
|
||||
CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -68,6 +69,7 @@ const getNestedFieldMetadataDetails = ({
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +135,7 @@ const handleNestedCompositeField = ({
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -14,6 +15,7 @@ export const validateSingleKeyForGroupByOrThrow = ({
|
||||
throw new GraphqlQueryRunnerException(
|
||||
errorMessage,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { CONNECTION_MAX_DEPTH } from 'src/engine/api/graphql/graphql-query-runner/constants/connection-max-depth.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
@@ -159,6 +160,7 @@ export class ObjectRecordsToGraphqlConnectionHelper {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Maximum depth of ${CONNECTION_MAX_DEPTH} reached`,
|
||||
GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -147,6 +148,7 @@ export class ProcessNestedRelationsV2Helper {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Relation settings not found for field ${sourceFieldName}`,
|
||||
GraphqlQueryRunnerExceptionCode.RELATION_SETTINGS_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,6 +273,7 @@ export class ProcessNestedRelationsV2Helper {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Field ${sourceFieldName} not found on object ${parentObjectMetadataItem.nameSingular}`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,6 +289,7 @@ export class ProcessNestedRelationsV2Helper {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Relation target object metadata id or field metadata id not found for field ${sourceFieldName}`,
|
||||
GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { findPostgresDefaultNullEquivalentValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -149,6 +150,7 @@ export const computeWhereConditionParts = ({
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Operator "${operator}" is not supported`,
|
||||
GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
|
||||
export interface CursorData {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -20,6 +21,7 @@ export const decodeCursor = <T = CursorData>(cursor: string): T => {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid cursor: ${cursor}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_CURSOR,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+3
@@ -1,3 +1,4 @@
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -14,6 +15,7 @@ export const getTargetObjectMetadataOrThrow = (
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
|
||||
GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export const getTargetObjectMetadataOrThrow = (
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Target object metadata not found for field ${fieldMetadata.name}`,
|
||||
GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ export const assertIsValidUuid = (value: string) => {
|
||||
throw new WorkspaceQueryRunnerException(
|
||||
`Value "${value}" is not a valid UUID`,
|
||||
WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: msg`Invalid UUID format.` },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import {
|
||||
type WorkspacePreQueryHookInstance,
|
||||
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
@@ -148,6 +149,7 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Unsupported payload type: ${payload}`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_POST_HOOK_PAYLOAD,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+23
-13
@@ -1,6 +1,8 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
@@ -17,18 +19,26 @@ export const WorkspaceQueryRunnerExceptionCode = appendCommonExceptionCode({
|
||||
NO_ROWS_AFFECTED: 'NO_ROWS_AFFECTED',
|
||||
} as const);
|
||||
|
||||
const workspaceQueryRunnerExceptionUserFriendlyMessages: Record<
|
||||
keyof typeof WorkspaceQueryRunnerExceptionCode,
|
||||
MessageDescriptor
|
||||
> = {
|
||||
INVALID_QUERY_INPUT: msg`Invalid query input.`,
|
||||
DATA_NOT_FOUND: msg`Data not found.`,
|
||||
QUERY_TIMEOUT: msg`Query timed out.`,
|
||||
QUERY_VIOLATES_UNIQUE_CONSTRAINT: msg`A record with this value already exists.`,
|
||||
QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT: msg`Cannot complete operation due to related records.`,
|
||||
TOO_MANY_ROWS_AFFECTED: msg`Too many records affected.`,
|
||||
NO_ROWS_AFFECTED: msg`No records were affected.`,
|
||||
INTERNAL_SERVER_ERROR: msg`An unexpected error occurred.`,
|
||||
const getWorkspaceQueryRunnerExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof WorkspaceQueryRunnerExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_UNIQUE_CONSTRAINT:
|
||||
return msg`A record with this value already exists.`;
|
||||
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT:
|
||||
return msg`Cannot complete operation due to related records.`;
|
||||
case WorkspaceQueryRunnerExceptionCode.TOO_MANY_ROWS_AFFECTED:
|
||||
return msg`Too many records affected.`;
|
||||
case WorkspaceQueryRunnerExceptionCode.NO_ROWS_AFFECTED:
|
||||
return msg`No records were affected.`;
|
||||
case WorkspaceQueryRunnerExceptionCode.QUERY_TIMEOUT:
|
||||
case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND:
|
||||
case WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
|
||||
case WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return STANDARD_ERROR_MESSAGE;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class WorkspaceQueryRunnerException extends CustomException<
|
||||
@@ -42,7 +52,7 @@ export class WorkspaceQueryRunnerException extends CustomException<
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
workspaceQueryRunnerExceptionUserFriendlyMessages[code],
|
||||
getWorkspaceQueryRunnerExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user