From d998e3b92c7c8ba1a5a76f21e3ccd2d1e11a310d Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:27:34 +0500 Subject: [PATCH] Display a CTA to view the existing duplicate when adding a duplicate email/domain. (#16483) Closes [89](https://github.com/twentyhq/core-team-issues/issues/89) ## Problem When users attempt to create or update a record with a duplicate value for a unique field (e.g., duplicate email or domain name), they receive a generic error message: "This record already exists. Please check your data and try again." This provides no actionable way to locate and view the existing conflicting record, forcing users to manually search for it. ## Solution This PR enhances duplicate key constraint error handling to automatically detect the conflicting record and display a "View existing record" link in the error notification. When clicked, users are navigated directly to the existing record's detail page. ## Backend Changes ### 1. PostgreSQL Error Parsing (`parse-postgres-constraint-error.util.ts`) - Extracts structured information from PostgreSQL `QueryFailedError` messages. ### 2. Conflicting Record Lookup (`find-conflicting-record.util.ts`) - Queries the database to find the existing record with the conflicting value ### 3. Error Handling Orchestration (`handle-duplicate-key-error.util.ts`) - Parses PostgreSQL error to extract column name and conflicting value - Attempts to find the conflicting record - Enriches `TwentyORMException` with `conflictingRecordId` and `conflictingObjectNameSingular` if found ### 4. Exception Computation Updates (`compute-twenty-orm-exception.ts`) - Made function `async` and added optional `entityManager` and `internalContext` parameters - Needed to support async database queries for conflicting record lookup ### 5. GraphQL Error Handler (`twenty-orm-graphql-api-exception-handler.util.ts`) - **Changes**: Enhanced `DUPLICATE_ENTRY_DETECTED` case to include `conflictingRecordId` and `conflictingObjectNameSingular` in GraphQL error extensions ## Frontend Changes ### 1. Error Extraction Utility (`get-conflicting-record-from-apollo-error.util.ts`) - Accesses GraphQL error extensions - Validates that both `conflictingRecordId` and `conflictingObjectNameSingular` exist and are strings - Returns `null` if validation fails ### 2. SnackBar Enhancement (`useSnackBar.ts`) - Extracts conflicting record info from Apollo error - Constructs URL using `getAppPath` utility - Adds link object to snackbar options with text "View existing record" image --- .../snack-bar-manager/components/SnackBar.tsx | 24 ++++-- .../components/SnackBarProvider.tsx | 8 +- .../snack-bar-manager/hooks/useSnackBar.ts | 4 + .../utils/build-error-action.util.ts | 28 +++++++ ...nflicting-record-from-apollo-error.util.ts | 35 +++++++++ .../utils/find-conflicting-record.util.ts | 76 +++++++++++++++++++ .../utils/handle-duplicate-key-error.util.ts | 63 ++++++++++++--- .../parse-postgres-constraint-error.util.ts | 37 +++++++++ .../workspace-entity-manager.ts | 7 +- .../compute-twenty-orm-exception.ts | 20 ++++- .../workspace-delete-query-builder.ts | 2 +- .../workspace-insert-query-builder.ts | 7 +- .../workspace-select-query-builder.ts | 22 +++--- .../workspace-soft-delete-query-builder.ts | 2 +- .../workspace-update-query-builder.ts | 15 +++- ...-orm-graphql-api-exception-handler.util.ts | 26 ++++++- 16 files changed, 335 insertions(+), 41 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/utils/build-error-action.util.ts create mode 100644 packages/twenty-front/src/utils/get-conflicting-record-from-apollo-error.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/find-conflicting-record.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/parse-postgres-constraint-error.util.ts 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: