From ffcbfa6215292dcac0d411a0392159aa75fe7b31 Mon Sep 17 00:00:00 2001 From: Nabhag Motivaras <65061890+Nabhag8848@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:20:14 +0530 Subject: [PATCH] fix: standard object metadata override (#13215) # Issue - fix #13156, related #13105 --------- Co-authored-by: Charles Bochet --- packages/twenty-server/@types/express.d.ts | 3 + .../hooks/use-cached-metadata.ts | 5 +- .../engine/core-modules/auth/auth.resolver.ts | 8 +- .../email-verification.resolver.ts | 4 +- .../core-modules/i18n/i18n.middleware.ts | 2 +- .../i18n/types/i18n-context.type.ts | 4 +- .../engine/dataloaders/dataloader.service.ts | 4 +- .../field-metadata/field-metadata.resolver.ts | 2 +- ...d-metadata-standard-override.util.spec.ts} | 96 +++- ...-field-metadata-standard-override.util.ts} | 2 +- .../object-metadata.resolver.ts | 21 +- .../object-metadata.service.ts | 32 +- ...ct-metadata-standard-override.util.spec.ts | 481 ++++++++++++++++++ ...-object-metadata-standard-override.util.ts | 61 +++ .../engine/middlewares/middleware.service.ts | 6 + 15 files changed, 645 insertions(+), 86 deletions(-) rename packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/{resolve-overridable-string.spec.ts => resolve-field-metadata-standard-override.util.spec.ts} (81%) rename packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/{resolve-overridable-string.util.ts => resolve-field-metadata-standard-override.util.ts} (96%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 640cb67a6..0136097c4 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -1,3 +1,5 @@ +import { APP_LOCALES } from 'twenty-shared/translations'; + import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -9,6 +11,7 @@ declare module 'express-serve-static-core' { user?: User | null; apiKey?: ApiKey | null; userWorkspace?: UserWorkspace; + locale: keyof typeof APP_LOCALES; workspace?: Workspace; workspaceId?: string; workspaceMetadataVersion?: number; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/hooks/use-cached-metadata.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/hooks/use-cached-metadata.ts index c30a2e5de..24695ff3f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/hooks/use-cached-metadata.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/hooks/use-cached-metadata.ts @@ -18,10 +18,7 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin { const workspaceMetadataVersion = serverContext.req.workspaceMetadataVersion ?? '0'; const operationName = getOperationName(serverContext); - const locale = - serverContext.req.userWorkspace?.locale ?? - serverContext.req.headers['x-locale'] ?? - ''; + const locale = serverContext.req.locale; const localeCacheKey = isNonEmptyString(locale) ? `:${locale}` : ''; const queryHash = createHash('sha256') .update(serverContext.req.body.query) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index f534c8d5c..b4d43a623 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -519,7 +519,7 @@ export class AuthResolver { return await this.resetPasswordService.sendEmailPasswordResetLink( resetToken, emailPasswordResetInput.email, - context.req.headers['x-locale'] || SOURCE_LOCALE, + context.req.locale, ); } @@ -535,11 +535,7 @@ export class AuthResolver { passwordResetToken, ); - await this.authService.updatePassword( - id, - newPassword, - context.req.headers['x-locale'] || SOURCE_LOCALE, - ); + await this.authService.updatePassword(id, newPassword, context.req.locale); return await this.resetPasswordService.invalidatePasswordResetToken(id); } diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts index 1394170ff..86c19cdd9 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.resolver.ts @@ -1,8 +1,6 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Context, Mutation, Resolver } from '@nestjs/graphql'; -import { SOURCE_LOCALE } from 'twenty-shared/translations'; - import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input'; import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output'; @@ -41,7 +39,7 @@ export class EmailVerificationResolver { return await this.emailVerificationService.resendEmailVerificationToken( resendEmailVerificationTokenInput.email, workspace, - context.req.headers['x-locale'] ?? SOURCE_LOCALE, + context.req.locale, ); } } diff --git a/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts b/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts index 300ea55ef..2fd7b54fa 100644 --- a/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts +++ b/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts @@ -7,7 +7,7 @@ import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; @Injectable() export class I18nMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { - const locale = req.headers['x-locale'] as keyof typeof APP_LOCALES; + const locale = req.locale; if (locale && Object.values(APP_LOCALES).includes(locale)) { i18n.activate(locale); diff --git a/packages/twenty-server/src/engine/core-modules/i18n/types/i18n-context.type.ts b/packages/twenty-server/src/engine/core-modules/i18n/types/i18n-context.type.ts index 96556156e..21d9e8f54 100644 --- a/packages/twenty-server/src/engine/core-modules/i18n/types/i18n-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/i18n/types/i18n-context.type.ts @@ -1,8 +1,6 @@ import { APP_LOCALES } from 'twenty-shared/translations'; export type I18nContext = { req: { - headers: { - 'x-locale': (typeof APP_LOCALES)[keyof typeof APP_LOCALES] | undefined; - }; + locale: keyof typeof APP_LOCALES; }; }; diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts index 675819154..1c59198dd 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts @@ -12,7 +12,7 @@ import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; -import { resolveOverridableString } from 'src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util'; +import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util'; import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -177,7 +177,7 @@ export class DataloaderService { >( (acc, field) => ({ ...acc, - [field]: resolveOverridableString( + [field]: resolveFieldMetadataStandardOverride( fieldMetadata, field, dataLoaderParams[0].locale, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 34c13a853..6964cbc13 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -81,7 +81,7 @@ export class FieldMetadataResolver { try { const updatedInput = (await this.beforeUpdateOneField.run(input, { workspaceId, - locale: context.req.headers['x-locale'], + locale: context.req.locale, })) as UpdateOneFieldMetadataInput; return await this.fieldMetadataService.updateOne(updatedInput.id, { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts similarity index 81% rename from packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts rename to packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts index f84ee1661..b8540d2d4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts @@ -2,7 +2,7 @@ import { i18n } from '@lingui/core'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; -import { resolveOverridableString } from 'src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util'; +import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util'; jest.mock('@lingui/core'); jest.mock('src/engine/core-modules/i18n/utils/generateMessageId'); @@ -12,7 +12,7 @@ const mockGenerateMessageId = generateMessageId as jest.MockedFunction< typeof generateMessageId >; -describe('resolveOverridableString', () => { +describe('resolveFieldMetadataStandardOverride', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -27,7 +27,11 @@ describe('resolveOverridableString', () => { standardOverrides: undefined, }; - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(result).toBe('Custom Label'); }); @@ -41,7 +45,7 @@ describe('resolveOverridableString', () => { standardOverrides: undefined, }; - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'description', undefined, @@ -59,7 +63,7 @@ describe('resolveOverridableString', () => { standardOverrides: undefined, }; - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'icon', SOURCE_LOCALE, @@ -81,7 +85,11 @@ describe('resolveOverridableString', () => { }, }; - const result = resolveOverridableString(fieldMetadata, 'icon', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'icon', + 'fr-FR', + ); expect(result).toBe('override-icon'); }); @@ -104,11 +112,15 @@ describe('resolveOverridableString', () => { }, }; - expect(resolveOverridableString(fieldMetadata, 'label', 'fr-FR')).toBe( - 'Libellé traduit', - ); expect( - resolveOverridableString(fieldMetadata, 'description', 'fr-FR'), + resolveFieldMetadataStandardOverride(fieldMetadata, 'label', 'fr-FR'), + ).toBe('Libellé traduit'); + expect( + resolveFieldMetadataStandardOverride( + fieldMetadata, + 'description', + 'fr-FR', + ), ).toBe('Description traduite'); }); @@ -130,7 +142,11 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(result).toBe('Standard Label'); }); @@ -153,7 +169,7 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'description', 'fr-FR', @@ -180,7 +196,7 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'label', undefined, @@ -205,13 +221,25 @@ describe('resolveOverridableString', () => { }; expect( - resolveOverridableString(fieldMetadata, 'label', SOURCE_LOCALE), + resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + SOURCE_LOCALE, + ), ).toBe('Overridden Label'); expect( - resolveOverridableString(fieldMetadata, 'description', SOURCE_LOCALE), + resolveFieldMetadataStandardOverride( + fieldMetadata, + 'description', + SOURCE_LOCALE, + ), ).toBe('Overridden Description'); expect( - resolveOverridableString(fieldMetadata, 'icon', SOURCE_LOCALE), + resolveFieldMetadataStandardOverride( + fieldMetadata, + 'icon', + SOURCE_LOCALE, + ), ).toBe('overridden-icon'); }); @@ -229,7 +257,11 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(result).toBe('Standard Label'); }); @@ -248,7 +280,7 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'label', SOURCE_LOCALE, @@ -271,7 +303,7 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('generated-message-id'); mockI18n._.mockReturnValue('generated-message-id'); - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'label', SOURCE_LOCALE, @@ -294,7 +326,11 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('standard.label.message.id'); mockI18n._.mockReturnValue('Libellé traduit automatiquement'); - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label'); expect(mockI18n._).toHaveBeenCalledWith('standard.label.message.id'); @@ -315,7 +351,11 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue(messageId); mockI18n._.mockReturnValue(messageId); - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(result).toBe('Standard Label'); }); @@ -338,7 +378,11 @@ describe('resolveOverridableString', () => { }, }; - const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'fr-FR', + ); expect(result).toBe('Translation Override'); expect(mockGenerateMessageId).not.toHaveBeenCalled(); @@ -356,7 +400,7 @@ describe('resolveOverridableString', () => { }, }; - const result = resolveOverridableString( + const result = resolveFieldMetadataStandardOverride( fieldMetadata, 'label', SOURCE_LOCALE, @@ -379,7 +423,11 @@ describe('resolveOverridableString', () => { mockGenerateMessageId.mockReturnValue('auto.translation.id'); mockI18n._.mockReturnValue('Auto Translated Label'); - const result = resolveOverridableString(fieldMetadata, 'label', 'de-DE'); + const result = resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + 'de-DE', + ); expect(result).toBe('Auto Translated Label'); expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label'); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts similarity index 96% rename from packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts rename to packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts index 45f026a98..2878c27aa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts @@ -6,7 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; -export const resolveOverridableString = ( +export const resolveFieldMetadataStandardOverride = ( fieldMetadata: Pick< FieldMetadataDTO, 'label' | 'description' | 'icon' | 'isCustom' | 'standardOverrides' diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts index e9abb1f4c..a9f6157b8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts @@ -27,6 +27,7 @@ import { import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util'; +import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @@ -48,10 +49,10 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveOverridableString( + return resolveObjectMetadataStandardOverride( objectMetadata, 'labelPlural', - context.req.headers['x-locale'], + context.req.locale, ); } @@ -60,10 +61,10 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveOverridableString( + return resolveObjectMetadataStandardOverride( objectMetadata, 'labelSingular', - context.req.headers['x-locale'], + context.req.locale, ); } @@ -72,10 +73,10 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveOverridableString( + return resolveObjectMetadataStandardOverride( objectMetadata, 'description', - context.req.headers['x-locale'], + context.req.locale, ); } @@ -85,10 +86,10 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveOverridableString( + return resolveObjectMetadataStandardOverride( objectMetadata, 'icon', - context.req.headers['x-locale'], + context.req.locale, ); } @@ -118,7 +119,7 @@ export class ObjectMetadataResolver { try { const updatedInput = (await this.beforeUpdateOneObject.run(input, { workspaceId, - locale: context.req.headers['x-locale'], + locale: context.req.locale, })) as UpdateOneObjectInput; return await this.objectMetadataService.updateOneObject( @@ -141,7 +142,7 @@ export class ObjectMetadataResolver { { objectMetadata, workspaceId: workspace.id, - locale: context.req.headers['x-locale'], + locale: context.req.locale, }, ); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index d8981d6a1..2f35973ad 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -1,10 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { i18n } from '@lingui/core'; import { Query, QueryOptions } from '@ptc-org/nestjs-query-core'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import { APP_LOCALES } from 'twenty-shared/translations'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { FindManyOptions, @@ -14,12 +12,10 @@ import { Repository, } from 'typeorm'; -import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input'; -import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; import { UpdateObjectPayload, UpdateOneObjectInput, @@ -405,6 +401,7 @@ export class ObjectMetadataService extends TypeOrmQueryService { - if (objectMetadata.isCustom) { - return objectMetadata[labelKey]; - } - - const translationValue = - // @ts-expect-error legacy noImplicitAny - objectMetadata.standardOverrides?.translations?.[locale]?.[labelKey]; - - if (isDefined(translationValue)) { - return translationValue; - } - - const messageId = generateMessageId(objectMetadata[labelKey] ?? ''); - const translatedMessage = i18n._(messageId); - - if (translatedMessage === messageId) { - return objectMetadata[labelKey] ?? ''; - } - - return translatedMessage; - } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts new file mode 100644 index 000000000..a4b4be2be --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts @@ -0,0 +1,481 @@ +import { i18n } from '@lingui/core'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util'; + +jest.mock('@lingui/core'); +jest.mock('src/engine/core-modules/i18n/utils/generateMessageId'); + +const mockI18n = i18n as jest.Mocked; +const mockGenerateMessageId = generateMessageId as jest.MockedFunction< + typeof generateMessageId +>; + +describe('resolveObjectMetadataStandardOverride', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Custom objects', () => { + it('should return the object value for custom labelSingular object', () => { + const objectMetadata = { + labelSingular: 'My Custom', + labelPlural: 'My Customs', + description: 'Custom Description', + icon: 'custom-icon', + isCustom: true, + standardOverrides: undefined, + } satisfies Pick< + ObjectMetadataDTO, + | 'labelPlural' + | 'labelSingular' + | 'description' + | 'icon' + | 'isCustom' + | 'standardOverrides' + >; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(result).toBe('My Custom'); + }); + + it('should return the object value for custom description object', () => { + const objectMetadata = { + labelSingular: 'My Custom', + labelPlural: 'My Customs', + description: 'Custom Description', + icon: 'custom-icon', + isCustom: true, + standardOverrides: undefined, + } satisfies Pick< + ObjectMetadataDTO, + | 'labelPlural' + | 'labelSingular' + | 'description' + | 'icon' + | 'isCustom' + | 'standardOverrides' + >; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'description', + undefined, + ); + + expect(result).toBe('Custom Description'); + }); + + it('should return the object value for custom icon object', () => { + const objectMetadata = { + labelSingular: 'My Custom', + labelPlural: 'My Customs', + description: 'Custom Description', + icon: 'custom-icon', + isCustom: true, + standardOverrides: undefined, + } satisfies Pick< + ObjectMetadataDTO, + | 'labelPlural' + | 'labelSingular' + | 'description' + | 'icon' + | 'isCustom' + | 'standardOverrides' + >; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'icon', + SOURCE_LOCALE, + ); + + expect(result).toBe('custom-icon'); + }); + }); + + describe('Standard objects - Icon overrides', () => { + it('should return override icon when available for standard object', () => { + const objectMetadata = { + labelSingular: 'My Custom', + labelPlural: 'My Customs', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + icon: 'override-icon', + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'icon', + 'fr-FR', + ); + + expect(result).toBe('override-icon'); + }); + }); + + describe('Standard objects - Translation overrides', () => { + it('should return translation override when available for non-icon objects', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + translations: { + 'fr-FR': { + labelSingular: 'Libellé traduit', + labelPlural: 'Libellés traduits', + description: 'Description traduite', + }, + }, + }, + }; + + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ), + ).toBe('Libellé traduit'); + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelPlural', + 'fr-FR', + ), + ).toBe('Libellés traduits'); + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'description', + 'fr-FR', + ), + ).toBe('Description traduite'); + }); + + it('should fallback when translation override is not available for the locale', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + translations: { + 'es-ES': { + labelSingular: 'Etiqueta en español', + labelPlural: 'Etiquetas en español', + description: 'Descripción en español', + }, + }, + }, + }; + + mockGenerateMessageId.mockReturnValue('generated-message-id'); + mockI18n._.mockReturnValue('generated-message-id'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(result).toBe('Standard Label'); + }); + + it('should fallback when translation override is not available for the labelKey', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + translations: { + 'fr-FR': { + labelPlural: 'Libellés traduits', + labelSingular: 'Libellé traduit', + }, + }, + }, + }; + + mockGenerateMessageId.mockReturnValue('generated-message-id'); + mockI18n._.mockReturnValue('generated-message-id'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'description', + 'fr-FR', + ); + + expect(result).toBe('Standard Description'); + }); + + it('should not use translation overrides when locale is undefined', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + translations: { + 'fr-FR': { + labelSingular: 'Libellé traduit', + labelPlural: 'Libellés traduits', + description: 'Description traduite', + }, + }, + }, + }; + + mockGenerateMessageId.mockReturnValue('generated-message-id'); + mockI18n._.mockReturnValue('generated-message-id'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + undefined, + ); + + expect(result).toBe('Standard Label'); + }); + }); + + describe('Standard objects - SOURCE_LOCALE overrides', () => { + it('should return direct override for SOURCE_LOCALE when available', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + labelSingular: 'Overridden Label', + labelPlural: 'Overridden Labels', + description: 'Overridden Description', + icon: 'overridden-icon', + }, + }; + + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + SOURCE_LOCALE, + ), + ).toBe('Overridden Label'); + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelPlural', + SOURCE_LOCALE, + ), + ).toBe('Overridden Labels'); + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'description', + SOURCE_LOCALE, + ), + ).toBe('Overridden Description'); + expect( + resolveObjectMetadataStandardOverride( + objectMetadata, + 'icon', + SOURCE_LOCALE, + ), + ).toBe('overridden-icon'); + }); + + it('should not use direct override for non-SOURCE_LOCALE', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + labelSingular: 'Overridden Label', + labelPlural: 'Overridden Labels', + }, + }; + + mockGenerateMessageId.mockReturnValue('generated-message-id'); + mockI18n._.mockReturnValue('generated-message-id'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(result).toBe('Standard Label'); + }); + + it('should not use undefined override for SOURCE_LOCALE', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + labelSingular: undefined, + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + SOURCE_LOCALE, + ); + + expect(result).toBe('Standard Label'); + }); + }); + + describe('Standard objects - Auto translation fallback', () => { + it('should return translated message when translation is available', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: undefined, + }; + + mockGenerateMessageId.mockReturnValue('standard.label.message.id'); + mockI18n._.mockReturnValue('Libellé traduit automatiquement'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label'); + expect(mockI18n._).toHaveBeenCalledWith('standard.label.message.id'); + expect(result).toBe('Libellé traduit automatiquement'); + }); + + it('should return original object value when no translation is found', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: undefined, + }; + + const messageId = 'standard.label.message.id'; + + mockGenerateMessageId.mockReturnValue(messageId); + mockI18n._.mockReturnValue(messageId); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(result).toBe('Standard Label'); + }); + }); + + describe('Priority order - Standard objects', () => { + it('should prioritize translation override over SOURCE_LOCALE override for non-SOURCE_LOCALE', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + labelSingular: 'Source Override', + labelPlural: 'Source Overrides', + translations: { + 'fr-FR': { + labelSingular: 'Translation Override', + labelPlural: 'Translation Overrides', + }, + }, + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + ); + + expect(result).toBe('Translation Override'); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + }); + + it('should prioritize SOURCE_LOCALE override over auto translation for SOURCE_LOCALE', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: { + labelSingular: 'Source Override', + labelPlural: 'Source Overrides', + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + SOURCE_LOCALE, + ); + + expect(result).toBe('Source Override'); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + }); + + it('should use auto translation when no overrides are available', () => { + const objectMetadata = { + labelSingular: 'Standard Label', + labelPlural: 'Standard Labels', + description: 'Standard Description', + icon: 'default-icon', + isCustom: false, + standardOverrides: {}, + }; + + mockGenerateMessageId.mockReturnValue('auto.translation.id'); + mockI18n._.mockReturnValue('Auto Translated Label'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'de-DE', + ); + + expect(result).toBe('Auto Translated Label'); + expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label'); + expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts new file mode 100644 index 000000000..42b2801e4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts @@ -0,0 +1,61 @@ +import { i18n } from '@lingui/core'; +import { isNonEmptyString } from '@sniptt/guards'; +import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + +export const resolveObjectMetadataStandardOverride = ( + objectMetadata: Pick< + ObjectMetadataDTO, + | 'labelPlural' + | 'labelSingular' + | 'description' + | 'icon' + | 'isCustom' + | 'standardOverrides' + >, + labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon', + locale: keyof typeof APP_LOCALES | undefined, +): string => { + if (objectMetadata.isCustom) { + return objectMetadata[labelKey] ?? ''; + } + + if ( + labelKey === 'icon' && + isDefined(objectMetadata.standardOverrides?.icon) + ) { + return objectMetadata.standardOverrides.icon; + } + + if ( + isDefined(objectMetadata.standardOverrides?.translations) && + isDefined(locale) && + labelKey !== 'icon' + ) { + const translationValue = + objectMetadata.standardOverrides.translations[locale]?.[labelKey]; + + if (isDefined(translationValue)) { + return translationValue; + } + } + + if ( + locale === SOURCE_LOCALE && + isNonEmptyString(objectMetadata.standardOverrides?.[labelKey]) + ) { + return objectMetadata.standardOverrides[labelKey] ?? ''; + } + + const messageId = generateMessageId(objectMetadata[labelKey] ?? ''); + const translatedMessage = i18n._(messageId); + + if (translatedMessage === messageId) { + return objectMetadata[labelKey] ?? ''; + } + + return translatedMessage; +}; diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 84a1bec27..9787064a2 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Request, Response } from 'express'; +import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; @@ -158,6 +159,11 @@ export class MiddlewareService { request.workspaceMemberId = data.workspaceMemberId; request.userWorkspaceId = data.userWorkspaceId; request.authProvider = data.authProvider; + + request.locale = + ((data.userWorkspace?.locale ?? + request.headers['x-locale']) as keyof typeof APP_LOCALES) ?? + SOURCE_LOCALE; } // eslint-disable-next-line @typescript-eslint/no-explicit-any