diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx index 048ed520e2..2e936cfd2c 100644 --- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx @@ -30,10 +30,9 @@ export type SnackBarProps = Pick, 'id'> & { duration?: number; icon?: ReactNode; message: string; - link?: { - href: string; - text: string; - }; + actionText?: string; + actionOnClick?: () => void; + actionTo?: string; detailedMessage?: string; onCancel?: () => void; onClose?: () => void; @@ -118,6 +117,10 @@ const StyledLink = styled(Link)` } `; +const StyledActionButton = styled.div` + padding-left: ${({ theme }) => theme.spacing(6)}; +`; + const defaultAriaLabelByVariant: Record = { [SnackBarVariant.Default]: 'Alert', [SnackBarVariant.Error]: 'Error', @@ -134,7 +137,9 @@ export const SnackBar = ({ id, message, detailedMessage, - link, + actionText, + actionOnClick, + actionTo, onCancel, onClose, role = 'status', @@ -230,7 +235,14 @@ export const SnackBar = ({ {isDefined(sanitizedDetailedMessage) && ( {sanitizedDetailedMessage} )} - {link && {link.text}} + {actionText && actionTo && ( + {actionText} + )} + {actionText && actionOnClick && !actionTo && ( + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBarProvider.tsx b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBarProvider.tsx index 6b4e645c9a..5876705b96 100644 --- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBarProvider.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBarProvider.tsx @@ -58,7 +58,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => { message, detailedMessage, variant, - link, + actionText, + actionOnClick, + actionTo, }) => ( { message, detailedMessage, variant, - link, + actionText, + actionOnClick, + actionTo, }} onClose={() => handleSnackBarClose(id)} /> diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts index 558f875d0e..96291fc8eb 100644 --- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/hooks/useSnackBar.ts @@ -8,6 +8,7 @@ import { snackBarInternalComponentState, type SnackBarOptions, } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalComponentState'; +import { buildErrorAction } from '@/ui/feedback/snack-bar-manager/utils/build-error-action.util'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { type ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; @@ -143,9 +144,12 @@ export const useSnackBar = () => { ? getErrorMessageFromApolloError(apolloError) : t`An error occurred.`; + const errorAction = buildErrorAction(apolloError); + setSnackBarQueue({ id: uuidv4(), message: errorMessage, + ...errorAction, ...options, variant: SnackBarVariant.Error, }); diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/build-error-action.util.ts b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/build-error-action.util.ts new file mode 100644 index 0000000000..fb8a16c0dc --- /dev/null +++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/build-error-action.util.ts @@ -0,0 +1,28 @@ +import { type ApolloError } from '@apollo/client'; +import { t } from '@lingui/core/macro'; +import { AppPath } from 'twenty-shared/types'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { getConflictingRecordFromApolloError } from '~/utils/get-conflicting-record-from-apollo-error.util'; +import { type SnackBarOptions } from '../states/snackBarInternalComponentState'; + +export const buildErrorAction = ( + apolloError?: ApolloError, +): Pick | null => { + if (!apolloError) { + return null; + } + + const conflictingRecord = getConflictingRecordFromApolloError(apolloError); + + if (isDefined(conflictingRecord)) { + return { + actionText: t`View existing record`, + actionTo: getAppPath(AppPath.RecordShowPage, { + objectNameSingular: conflictingRecord.conflictingObjectNameSingular, + objectRecordId: conflictingRecord.conflictingRecordId, + }), + }; + } + + return null; +}; diff --git a/packages/twenty-front/src/utils/get-conflicting-record-from-apollo-error.util.ts b/packages/twenty-front/src/utils/get-conflicting-record-from-apollo-error.util.ts new file mode 100644 index 0000000000..157bff3bd6 --- /dev/null +++ b/packages/twenty-front/src/utils/get-conflicting-record-from-apollo-error.util.ts @@ -0,0 +1,35 @@ +import { type ApolloError } from '@apollo/client'; +import { isDefined } from 'twenty-shared/utils'; + +export type ConflictingRecordInfo = { + conflictingRecordId: string; + conflictingObjectNameSingular: string; +}; + +export const getConflictingRecordFromApolloError = ( + error: ApolloError, +): ConflictingRecordInfo | null => { + const extensions = error.graphQLErrors?.[0]?.extensions; + + if (!extensions) { + return null; + } + + const conflictingRecordId = extensions.conflictingRecordId; + const conflictingObjectNameSingular = + extensions.conflictingObjectNameSingular; + + if ( + !isDefined(conflictingRecordId) || + !isDefined(conflictingObjectNameSingular) || + typeof conflictingRecordId !== 'string' || + typeof conflictingObjectNameSingular !== 'string' + ) { + return null; + } + + return { + conflictingRecordId, + conflictingObjectNameSingular, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/find-conflicting-record.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/find-conflicting-record.util.ts new file mode 100644 index 0000000000..d3ea578dcf --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/find-conflicting-record.util.ts @@ -0,0 +1,76 @@ +import { compositeTypeDefinitions } from 'twenty-shared/types'; +import { capitalize } from 'twenty-shared/utils'; + +import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; + +export const findConflictingRecord = async ( + columnName: string, + conflictingValue: string, + objectMetadata: FlatObjectMetadata, + internalContext: WorkspaceInternalContext, + entityManager: WorkspaceEntityManager, +): Promise<{ conflictingRecordId: string; fieldLabel: string } | null> => { + const flatFields = getFlatFieldsFromFlatObjectMetadata( + objectMetadata, + internalContext.flatFieldMetadataMaps, + ); + + const uniqueFields = flatFields.filter((field) => field.isUnique); + + const matchingField = uniqueFields.find((field) => { + const compositeType = compositeTypeDefinitions.get(field.type); + + if (!compositeType) { + return field.name === columnName; + } + + const property = compositeType.properties.find( + (prop) => prop.isIncludedInUniqueConstraint, + ); + + if (!property) { + return false; + } + + const expectedColumnName = `${field.name}${capitalize(property.name)}`; + + return expectedColumnName === columnName; + }); + + if (!matchingField) { + return null; + } + + const queryBuilder = entityManager.createQueryBuilder( + objectMetadata.nameSingular, + objectMetadata.nameSingular, + undefined, + { + shouldBypassPermissionChecks: true, + }, + ); + + queryBuilder.where(`"${columnName}" = :value`, { value: conflictingValue }); + queryBuilder.andWhere('"deletedAt" IS NULL'); + + try { + const conflictingRecord = await queryBuilder.getOne(); + + if (!conflictingRecord) { + return null; + } + + return { + conflictingRecordId: conflictingRecord.id, + fieldLabel: matchingField.label, + }; + } catch { + // If query fails (e.g., permission denied, record not found), return null + // This allows the duplicate error to still be shown without conflicting record link + return null; + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts index 12349f3167..e11acc1f38 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts @@ -1,27 +1,68 @@ import { msg } from '@lingui/core/macro'; -import { type QueryFailedError } from 'typeorm'; + +import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; -interface PostgreSQLError extends QueryFailedError { - detail?: string; +import { findConflictingRecord } from './find-conflicting-record.util'; +import { + parsePostgresConstraintError, + type PostgreSQLError, +} from './parse-postgres-constraint-error.util'; + +interface DuplicateKeyErrorWithMetadata extends TwentyORMException { + conflictingRecordId?: string; + conflictingObjectNameSingular?: string; } -export const handleDuplicateKeyError = ( - _error: PostgreSQLError, - _objectMetadata: FlatObjectMetadata, -) => { - // Since we no longer have indexMetadatas in FlatObjectMetadata, - // we provide a generic error message - throw new TwentyORMException( +export const handleDuplicateKeyError = async ( + error: PostgreSQLError, + objectMetadata: FlatObjectMetadata, + internalContext: WorkspaceInternalContext, + entityManager: WorkspaceEntityManager, +): Promise => { + const parsedError = parsePostgresConstraintError(error); + + if (!parsedError) { + return new TwentyORMException( + `A duplicate entry was detected`, + TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED, + { + userFriendlyMessage: msg`This record already exists. Please check your data and try again.`, + }, + ); + } + + const conflictingRecord = await findConflictingRecord( + parsedError.columnName, + parsedError.conflictingValue, + objectMetadata, + internalContext, + entityManager, + ); + + const fieldLabel = conflictingRecord?.fieldLabel; + const userFriendlyMessage = fieldLabel + ? msg`This ${fieldLabel} value is already in use. Please check your data and try again.` + : msg`This record already exists. Please check your data and try again.`; + + const exception: DuplicateKeyErrorWithMetadata = new TwentyORMException( `A duplicate entry was detected`, TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED, { - userFriendlyMessage: msg`This record already exists. Please check your data and try again.`, + userFriendlyMessage, }, ); + + if (conflictingRecord) { + exception.conflictingRecordId = conflictingRecord.conflictingRecordId; + exception.conflictingObjectNameSingular = objectMetadata.nameSingular; + } + + return exception; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/parse-postgres-constraint-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/parse-postgres-constraint-error.util.ts new file mode 100644 index 0000000000..8479aef14e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/parse-postgres-constraint-error.util.ts @@ -0,0 +1,37 @@ +import { type QueryFailedError } from 'typeorm'; + +export type PostgreSQLError = QueryFailedError & { + detail?: string; + driverError?: Error & { + detail?: string; + }; +}; + +export type ParsedConstraintError = { + columnName: string; + conflictingValue: string; +}; + +export const parsePostgresConstraintError = ( + error: PostgreSQLError, +): ParsedConstraintError | null => { + const errorDetail = error.detail; + + if (!errorDetail) { + return null; + } + + const detailMatch = errorDetail.match(/Key \(([^)]+)\)=\(([^)]+)\)/); + + if (!detailMatch) { + return null; + } + + const columnName = detailMatch[1].replace(/^["']|["']$/g, ''); + const conflictingValue = detailMatch[2]; + + return { + columnName, + conflictingValue, + }; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index ff512e94ca..235e8486b8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -1296,7 +1296,12 @@ export class WorkspaceEntityManager extends EntityManager { this.internalContext, ); - throw computeTwentyORMException(error, objectMetadataItem); + throw await computeTwentyORMException( + error, + objectMetadataItem, + this, + this.internalContext, + ); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/error-handling/compute-twenty-orm-exception.ts b/packages/twenty-server/src/engine/twenty-orm/error-handling/compute-twenty-orm-exception.ts index 71bc93c269..ea71e268c8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/error-handling/compute-twenty-orm-exception.ts +++ b/packages/twenty-server/src/engine/twenty-orm/error-handling/compute-twenty-orm-exception.ts @@ -2,10 +2,13 @@ import { msg } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { QueryFailedError } from 'typeorm'; +import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants'; import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util'; import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { TwentyORMException, TwentyORMExceptionCode, @@ -15,10 +18,12 @@ interface QueryFailedErrorWithCode extends QueryFailedError { code?: string; } -export const computeTwentyORMException = ( +export const computeTwentyORMException = async ( error: Error, objectMetadata?: FlatObjectMetadata, -) => { + entityManager?: WorkspaceEntityManager, + internalContext?: WorkspaceInternalContext, +): Promise => { if (error instanceof QueryFailedError) { if (error.message.includes('Query read timeout')) { return new TwentyORMException( @@ -34,9 +39,16 @@ export const computeTwentyORMException = ( error.message.includes( 'duplicate key value violates unique constraint', ) && - isDefined(objectMetadata) + isDefined(objectMetadata) && + isDefined(entityManager) && + isDefined(internalContext) ) { - return handleDuplicateKeyError(error, objectMetadata); + return await handleDuplicateKeyError( + error, + objectMetadata, + internalContext, + entityManager, + ); } if (error.message.includes('invalid input value for')) { diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts index 5054513556..1ac5711008 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts @@ -140,7 +140,7 @@ export class WorkspaceDeleteQueryBuilder< affected: result.affected, }; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index 8281ce618e..f79c4e17f6 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -226,7 +226,12 @@ export class WorkspaceInsertQueryBuilder< this.internalContext, ); - throw computeTwentyORMException(error, objectMetadata); + throw await computeTwentyORMException( + error, + objectMetadata, + this.connection.manager as WorkspaceEntityManager, + this.internalContext, + ); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts index 643bcada01..8a83b9096b 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts @@ -95,7 +95,7 @@ export class WorkspaceSelectQueryBuilder< identifiers: result.identifiers, }; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } @@ -121,29 +121,29 @@ export class WorkspaceSelectQueryBuilder< return formattedResult; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any - override getRawOne(): Promise { + override async getRawOne(): Promise { try { this.validatePermissions(); return super.getRawOne(); } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any - override getRawMany(): Promise { + override async getRawMany(): Promise { try { this.validatePermissions(); return super.getRawMany(); } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } @@ -171,7 +171,7 @@ export class WorkspaceSelectQueryBuilder< return formattedResult; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } @@ -197,17 +197,17 @@ export class WorkspaceSelectQueryBuilder< return formattedResult[0]; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } - override getCount(): Promise { + override async getCount(): Promise { try { this.validatePermissions(); return super.getCount(); } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } @@ -240,7 +240,7 @@ export class WorkspaceSelectQueryBuilder< return [formattedResult, count]; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts index 28e911ca30..e5c21cd0ed 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts @@ -140,7 +140,7 @@ export class WorkspaceSoftDeleteQueryBuilder< affected: after.affected, }; } catch (error) { - throw computeTwentyORMException(error); + throw await computeTwentyORMException(error); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index e4a83908df..3ccac8d6c5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -19,6 +19,7 @@ import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-contex import { type QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type'; import { type RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; import { type RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; +import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { computeTwentyORMException } from 'src/engine/twenty-orm/error-handling/compute-twenty-orm-exception'; import { TwentyORMException, @@ -223,7 +224,12 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); - throw computeTwentyORMException(error, objectMetadata); + throw await computeTwentyORMException( + error, + objectMetadata, + this.connection.manager as WorkspaceEntityManager, + this.internalContext, + ); } } @@ -373,7 +379,12 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); - throw computeTwentyORMException(error, objectMetadata); + throw await computeTwentyORMException( + error, + objectMetadata, + this.connection.manager as WorkspaceEntityManager, + this.internalContext, + ); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts index b75e6a68d7..948883ff0a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts @@ -1,15 +1,39 @@ +import { isDefined } from 'twenty-shared/utils'; + import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { type TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +interface DuplicateKeyErrorWithMetadata extends TwentyORMException { + conflictingRecordId?: string; + conflictingObjectNameSingular?: string; +} + export const twentyORMGraphqlApiExceptionHandler = ( error: TwentyORMException, ) => { switch (error.code) { + case TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED: { + const duplicateKeyError: DuplicateKeyErrorWithMetadata = error; + + const extensions: Record = { + userFriendlyMessage: error.userFriendlyMessage, + ...(isDefined(duplicateKeyError.conflictingRecordId) && + isDefined(duplicateKeyError.conflictingObjectNameSingular) + ? { + conflictingRecordId: duplicateKeyError.conflictingRecordId, + conflictingObjectNameSingular: + duplicateKeyError.conflictingObjectNameSingular, + } + : {}), + }; + + throw new UserInputError(error.message, extensions); + } + case TwentyORMExceptionCode.INVALID_INPUT: - case TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED: case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND: case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED: case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR: