diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000100000-create-application-translation-core-table.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000100000-create-application-translation-core-table.ts new file mode 100644 index 0000000000..202f611521 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000100000-create-application-translation-core-table.ts @@ -0,0 +1,47 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.17.0', 1801000100000) +export class CreateApplicationTranslationCoreTableFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "core"."applicationTranslation" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "applicationRegistrationId" uuid, + "locale" text NOT NULL, + "messages" jsonb NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "deletedAt" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "PK_applicationTranslation_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_62da06a264eae9a1e84f6c611bd" FOREIGN KEY ("applicationRegistrationId") REFERENCES "core"."applicationRegistration"("id") ON DELETE CASCADE + )`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE" + ON "core"."applicationTranslation" ("applicationRegistrationId", "locale") + WHERE "deletedAt" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE" + ON "core"."applicationTranslation" ("locale") + WHERE "deletedAt" IS NULL AND "applicationRegistrationId" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE"`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "core"."applicationTranslation"`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index bfb825c0c1..aecc507456 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -81,6 +81,7 @@ import { AddPrimaryPublicDomainToApplicationFastInstanceCommand } from 'src/data import { MakePublicDomainApplicationIdNotNullSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-slow-1782281874769-make-public-domain-application-id-not-null'; import { AddServerTriggerSettingsToLogicFunctionFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function'; import { CreateDpaAgreementCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000020000-create-dpa-agreement-core-table'; +import { CreateApplicationTranslationCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000100000-create-application-translation-core-table'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -164,4 +165,5 @@ export const INSTANCE_COMMANDS = [ AddPrimaryPublicDomainToApplicationFastInstanceCommand, MakePublicDomainApplicationIdNotNullSlowInstanceCommand, CreateDpaAgreementCoreTableFastInstanceCommand, + CreateApplicationTranslationCoreTableFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts index 04c0b162ff..7b26ae71b5 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts @@ -5,6 +5,7 @@ import { ApplicationManifestMigrationService } from 'src/engine/core-modules/app import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver'; import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; +import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; @@ -18,6 +19,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace @Module({ imports: [ ApplicationModule, + ApplicationTranslationModule, ApplicationVariableEntityModule, FeatureFlagModule, FileStorageModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index 99887f63a0..e823c2b24a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -8,6 +8,7 @@ import { PackageJson } from 'type-fest'; import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service'; import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; +import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service'; import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { @@ -37,6 +38,7 @@ export class ApplicationSyncService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly fileStorageService: FileStorageService, + private readonly applicationTranslationSyncService: ApplicationTranslationSyncService, @Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN) private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory, ) {} @@ -71,6 +73,24 @@ export class ApplicationSyncService { dryRun, }); + if (!dryRun && isDefined(ownerFlatApplication.applicationRegistrationId)) { + // Translation sync runs after the metadata migration is already applied + // and is non-critical to the application itself, so a failure here must + // never abort an otherwise successful install/sync. It is idempotent and + // self-heals on the next sync. + try { + await this.applicationTranslationSyncService.syncFromManifest({ + applicationRegistrationId: + ownerFlatApplication.applicationRegistrationId, + translations: manifest.translations, + }); + } catch (error) { + this.logger.warn( + `Failed to sync application translations for registration ${ownerFlatApplication.applicationRegistrationId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + this.logger.log( `Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`, ); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-cache.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-cache.service.ts new file mode 100644 index 0000000000..2c98b7288a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-cache.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type APP_LOCALES } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity'; + +type ApplicationCatalogsByLocale = Partial< + Record> +>; + +type ApplicationTranslationCacheEntry = { + catalogsByLocale: ApplicationCatalogsByLocale; + loadedAt: number; +}; + +const CACHE_TTL_MS = 30_000; + +const EMPTY_CATALOG: Record = {}; + +@Injectable() +export class ApplicationTranslationCacheService { + private readonly cache = new Map(); + + constructor( + // applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository + @InjectRepository(ApplicationTranslationEntity) + private readonly applicationTranslationRepository: Repository, + ) {} + + async getCatalog({ + applicationRegistrationId, + locale, + }: { + applicationRegistrationId: string; + locale: keyof typeof APP_LOCALES; + }): Promise> { + const entry = await this.getOrLoadEntry(applicationRegistrationId); + + return entry.catalogsByLocale[locale] ?? EMPTY_CATALOG; + } + + invalidate(applicationRegistrationId: string): void { + this.cache.delete(applicationRegistrationId); + } + + private async getOrLoadEntry( + applicationRegistrationId: string, + ): Promise { + const cachedEntry = this.cache.get(applicationRegistrationId); + + if ( + isDefined(cachedEntry) && + Date.now() - cachedEntry.loadedAt < CACHE_TTL_MS + ) { + return cachedEntry; + } + + const rows = await this.applicationTranslationRepository.find({ + where: { applicationRegistrationId }, + }); + + const catalogsByLocale: ApplicationCatalogsByLocale = {}; + + for (const row of rows) { + catalogsByLocale[row.locale] = row.messages; + } + + const entry: ApplicationTranslationCacheEntry = { + catalogsByLocale, + loadedAt: Date.now(), + }; + + this.cache.set(applicationRegistrationId, entry); + + return entry; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-sync.service.ts new file mode 100644 index 0000000000..9fdaa8b7d7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation-sync.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type TranslationsManifest } from 'twenty-shared/application'; +import { type APP_LOCALES } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service'; +import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity'; + +@Injectable() +export class ApplicationTranslationSyncService { + constructor( + // applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository + @InjectRepository(ApplicationTranslationEntity) + private readonly applicationTranslationRepository: Repository, + private readonly applicationTranslationCacheService: ApplicationTranslationCacheService, + ) {} + + async syncFromManifest({ + applicationRegistrationId, + translations, + }: { + applicationRegistrationId: string; + translations: TranslationsManifest | undefined; + }): Promise { + const existingRows = await this.applicationTranslationRepository.find({ + where: { applicationRegistrationId }, + withDeleted: true, + }); + + const existingRowByLocale = new Map< + keyof typeof APP_LOCALES, + ApplicationTranslationEntity + >(); + + for (const row of existingRows) { + const currentRow = existingRowByLocale.get(row.locale); + + const shouldPreferRow = + !isDefined(currentRow) || + (isDefined(currentRow.deletedAt) && !isDefined(row.deletedAt)); + + if (shouldPreferRow) { + existingRowByLocale.set(row.locale, row); + } + } + + const manifestLocales = new Set(); + const upsertPromises: Promise[] = []; + + for (const [locale, messages] of Object.entries(translations ?? {}) as [ + keyof typeof APP_LOCALES, + Record, + ][]) { + manifestLocales.add(locale); + + const existingRow = existingRowByLocale.get(locale); + + if (isDefined(existingRow)) { + upsertPromises.push( + this.applicationTranslationRepository.update(existingRow.id, { + messages, + deletedAt: null, + }), + ); + } else { + upsertPromises.push( + this.applicationTranslationRepository.insert({ + applicationRegistrationId, + locale, + messages, + }), + ); + } + } + + await Promise.all(upsertPromises); + + const rowsToSoftDelete = existingRows.filter( + (row) => !manifestLocales.has(row.locale) && !isDefined(row.deletedAt), + ); + + if (rowsToSoftDelete.length > 0) { + await this.applicationTranslationRepository.softDelete( + rowsToSoftDelete.map((row) => row.id), + ); + } + + this.applicationTranslationCacheService.invalidate( + applicationRegistrationId, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.entity.ts new file mode 100644 index 0000000000..fdba855d2f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.entity.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { type APP_LOCALES } from 'twenty-shared/translations'; + +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; + +@Entity({ name: 'applicationTranslation', schema: 'core' }) +@Index( + 'IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE', + ['applicationRegistrationId', 'locale'], + { + unique: true, + where: '"deletedAt" IS NULL', + }, +) +@Index('IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE', ['locale'], { + unique: true, + where: '"deletedAt" IS NULL AND "applicationRegistrationId" IS NULL', +}) +export class ApplicationTranslationEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true, type: 'uuid' }) + applicationRegistrationId: string | null; + + @ManyToOne(() => ApplicationRegistrationEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'applicationRegistrationId' }) + applicationRegistration: Relation | null; + + @Column({ type: 'text' }) + locale: keyof typeof APP_LOCALES; + + @Column({ type: 'jsonb', default: {} }) + messages: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt: Date | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.module.ts new file mode 100644 index 0000000000..707ab0134c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-translation/application-translation.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service'; +import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service'; +import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApplicationTranslationEntity])], + providers: [ + ApplicationTranslationCacheService, + ApplicationTranslationSyncService, + ], + exports: [ + ApplicationTranslationCacheService, + ApplicationTranslationSyncService, + ], +}) +export class ApplicationTranslationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/i18n/utils/__tests__/translate-standard-label.util.spec.ts b/packages/twenty-server/src/engine/core-modules/i18n/utils/__tests__/translate-standard-label.util.spec.ts new file mode 100644 index 0000000000..3601bfbdd1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/i18n/utils/__tests__/translate-standard-label.util.spec.ts @@ -0,0 +1,116 @@ +import { type I18n } from '@lingui/core'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util'; + +jest.mock('src/engine/core-modules/i18n/utils/generateMessageId'); + +const mockGenerateMessageId = generateMessageId as jest.MockedFunction< + typeof generateMessageId +>; + +describe('translateStandardLabel', () => { + let mockI18n: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockI18n = { + _: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('should return the source value when it is empty', () => { + const result = translateStandardLabel({ + sourceValue: '', + isStandardApp: true, + applicationCatalog: undefined, + i18nInstance: mockI18n, + }); + + expect(result).toBe(''); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + }); + + it('should resolve from the application catalog when provided', () => { + mockGenerateMessageId.mockReturnValue('company-id'); + + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: false, + applicationCatalog: { 'company-id': 'Entreprise' }, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Entreprise'); + expect(mockI18n._).not.toHaveBeenCalled(); + }); + + it('should fall back to the source value when the catalog has no matching entry', () => { + mockGenerateMessageId.mockReturnValue('missing-id'); + + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: false, + applicationCatalog: {}, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Company'); + }); + + it('should prefer the catalog over the standard bundle for an application', () => { + mockGenerateMessageId.mockReturnValue('company-id'); + mockI18n._.mockReturnValue('Bundle Translation'); + + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: true, + applicationCatalog: { 'company-id': 'Entreprise' }, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Entreprise'); + expect(mockI18n._).not.toHaveBeenCalled(); + }); + + it('should resolve from the standard bundle when no catalog is provided', () => { + mockGenerateMessageId.mockReturnValue('company-id'); + mockI18n._.mockReturnValue('Entreprise'); + + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: true, + applicationCatalog: undefined, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Entreprise'); + expect(mockI18n._).toHaveBeenCalledWith('company-id'); + }); + + it('should return the source value when the standard bundle has no translation', () => { + mockGenerateMessageId.mockReturnValue('company-id'); + mockI18n._.mockReturnValue('company-id'); + + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: true, + applicationCatalog: undefined, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Company'); + }); + + it('should return the source value for a non-standard app without a catalog', () => { + const result = translateStandardLabel({ + sourceValue: 'Company', + isStandardApp: false, + applicationCatalog: undefined, + i18nInstance: mockI18n, + }); + + expect(result).toBe('Company'); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/i18n/utils/translate-standard-label.util.ts b/packages/twenty-server/src/engine/core-modules/i18n/utils/translate-standard-label.util.ts new file mode 100644 index 0000000000..58725960fe --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/i18n/utils/translate-standard-label.util.ts @@ -0,0 +1,39 @@ +import { type I18n } from '@lingui/core'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; + +export const translateStandardLabel = ({ + sourceValue, + isStandardApp, + applicationCatalog, + i18nInstance, +}: { + sourceValue: string; + isStandardApp: boolean; + applicationCatalog: Record | undefined; + i18nInstance: I18n; +}): string => { + if (!isNonEmptyString(sourceValue)) { + return sourceValue ?? ''; + } + + if (!isDefined(applicationCatalog) && !isStandardApp) { + return sourceValue; + } + + const messageId = generateMessageId(sourceValue); + + if (isDefined(applicationCatalog)) { + return applicationCatalog[messageId] ?? sourceValue; + } + + if (isStandardApp) { + const translatedMessage = i18nInstance._(messageId); + + return translatedMessage === messageId ? sourceValue : translatedMessage; + } + + return sourceValue; +}; diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts index 439d3d7feb..880250ee8a 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts @@ -1,6 +1,7 @@ import type DataLoader from 'dataloader'; import { + type ApplicationRegistrationIdLoaderPayload, type FieldMetadataLoaderPayload, type IndexFieldMetadataLoaderPayload, type IndexMetadataLoaderPayload, @@ -98,4 +99,9 @@ export interface IDataloaders { StandardApplicationIdLoaderPayload, string >; + + applicationRegistrationIdLoader: DataLoader< + ApplicationRegistrationIdLoaderPayload, + string | null + >; } diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.module.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.module.ts index 202e45d8fb..79db79368c 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.module.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module'; +import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; import { DataloaderService } from 'src/engine/dataloaders/dataloader.service'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; @@ -10,6 +11,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata FieldMetadataModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, ApplicationRegistrationVariableModule, + ApplicationTranslationModule, ], providers: [DataloaderService], exports: [DataloaderService], diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts index 8241056e0b..f735344f68 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts @@ -8,6 +8,8 @@ import { isDefined } from 'twenty-shared/utils'; import { type IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface'; import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; +import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service'; +import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { filterMorphRelationDuplicateFields } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util'; @@ -122,12 +124,18 @@ export type StandardApplicationIdLoaderPayload = { workspaceId: string; }; +export type ApplicationRegistrationIdLoaderPayload = { + workspaceId: string; + applicationId: string; +}; + @Injectable() export class DataloaderService { constructor( private readonly i18nService: I18nService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, + private readonly applicationTranslationCacheService: ApplicationTranslationCacheService, ) {} createLoaders(): IDataloaders { @@ -150,6 +158,8 @@ export class DataloaderService { const isConfiguredLoader = this.createIsConfiguredLoader(); const standardApplicationIdLoader = this.createStandardApplicationIdLoader(); + const applicationRegistrationIdLoader = + this.createApplicationRegistrationIdLoader(); return { relationLoader, @@ -167,6 +177,7 @@ export class DataloaderService { viewFilterGroupsByViewIdLoader, isConfiguredLoader, standardApplicationIdLoader, + applicationRegistrationIdLoader, }; } @@ -298,37 +309,66 @@ export class DataloaderService { return new DataLoader( async (dataLoaderParams: FieldMetadataLoaderPayload[]) => { const locale = dataLoaderParams[0].locale; - const i18nInstance = this.i18nService.getI18nInstance( - locale ?? SOURCE_LOCALE, - ); + const safeLocale = locale ?? SOURCE_LOCALE; + const i18nInstance = this.i18nService.getI18nInstance(safeLocale); const workspaceId = dataLoaderParams[0].workspaceId; const objectMetadataIds = dataLoaderParams.map( (dataLoaderParam) => dataLoaderParam.objectMetadata.id, ); - const { flatFieldMetadataMaps, flatObjectMetadataMaps } = + const { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + flatApplicationMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'], + flatMapsKeys: [ + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + 'flatApplicationMaps', + ], }, ); - const fieldMetadataCollection = objectMetadataIds.map( + const objectFlatFieldMetadatasList = objectMetadataIds.map( (objectMetadataId) => { const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ flatEntityId: objectMetadataId, flatEntityMaps: flatObjectMetadataMaps, }); - const objectFlatFieldMetadatas = - findManyFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityIds: flatObjectMetadata.fieldIds, - flatEntityMaps: flatFieldMetadataMaps, - }); + return findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: flatObjectMetadata.fieldIds, + flatEntityMaps: flatFieldMetadataMaps, + }); + }, + ); + + const applicationCatalogByRegistrationId = + await this.loadApplicationCatalogByRegistrationId({ + applicationIds: objectFlatFieldMetadatasList + .flat() + .map((flatFieldMetadata) => flatFieldMetadata.applicationId), + flatApplicationMaps, + locale: safeLocale, + }); + + const fieldMetadataCollection = objectFlatFieldMetadatasList.map( + (objectFlatFieldMetadatas) => { const overriddenFieldMetadataEntities = objectFlatFieldMetadatas.map((flatFieldMetadata) => { + const applicationRegistrationId = + flatApplicationMaps.byId[flatFieldMetadata.applicationId] + ?.applicationRegistrationId; + const applicationCatalog = isDefined(applicationRegistrationId) + ? applicationCatalogByRegistrationId.get( + applicationRegistrationId, + ) + : undefined; + return FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce( (acc, property) => ({ ...acc, @@ -341,9 +381,10 @@ export class DataloaderService { flatFieldMetadata.standardOverrides ?? undefined, }, property, - dataLoaderParams[0].locale, + locale, i18nInstance, belongsToTwentyStandardApp(flatFieldMetadata), + applicationCatalog, ), }), flatFieldMetadata, @@ -789,4 +830,65 @@ export class DataloaderService { }, ); } + + private createApplicationRegistrationIdLoader() { + return new DataLoader< + ApplicationRegistrationIdLoaderPayload, + string | null + >(async (params: ApplicationRegistrationIdLoaderPayload[]) => { + const workspaceId = params[0].workspaceId; + + const { flatApplicationMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatApplicationMaps'], + }, + ); + + return params.map( + ({ applicationId }) => + flatApplicationMaps.byId[applicationId]?.applicationRegistrationId ?? + null, + ); + }); + } + + private async loadApplicationCatalogByRegistrationId({ + applicationIds, + flatApplicationMaps, + locale, + }: { + applicationIds: string[]; + flatApplicationMaps: FlatApplicationCacheMaps; + locale: keyof typeof APP_LOCALES; + }): Promise>> { + const registrationIds = [ + ...new Set( + applicationIds + .map( + (applicationId) => + flatApplicationMaps.byId[applicationId] + ?.applicationRegistrationId, + ) + .filter(isDefined), + ), + ]; + + const catalogByRegistrationId = new Map>(); + + await Promise.all( + registrationIds.map(async (applicationRegistrationId) => { + const catalog = + await this.applicationTranslationCacheService.getCatalog({ + applicationRegistrationId, + locale, + }); + + catalogByRegistrationId.set(applicationRegistrationId, catalog); + }), + ); + + return catalogByRegistrationId; + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index e2fb308500..c7923cbf41 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -42,6 +43,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; @Module({ imports: [ ApplicationModule, + ApplicationTranslationModule, TokenModule, WorkspaceCacheStorageModule, FeatureFlagModule, 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 bc511e0898..839ca20972 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 @@ -5,6 +5,7 @@ import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -44,6 +45,7 @@ export class FieldMetadataResolver { constructor( private readonly fieldMetadataService: FieldMetadataService, private readonly i18nService: I18nService, + private readonly applicationTranslationCacheService: ApplicationTranslationCacheService, ) {} @ResolveField(() => Boolean, { @@ -67,12 +69,29 @@ export class FieldMetadataResolver { const standardApplicationId = await context.loaders.standardApplicationIdLoader.load({ workspaceId }); + const isStandardApp = fieldMetadata.applicationId === standardApplicationId; + + const applicationRegistrationId = isStandardApp + ? null + : await context.loaders.applicationRegistrationIdLoader.load({ + workspaceId, + applicationId: fieldMetadata.applicationId, + }); + + const applicationCatalog = isDefined(applicationRegistrationId) + ? await this.applicationTranslationCacheService.getCatalog({ + applicationRegistrationId, + locale: context.req.locale, + }) + : undefined; + return resolveFieldMetadataStandardOverride( fieldMetadata, labelKey, context.req.locale, i18n, - fieldMetadata.applicationId === standardApplicationId, + isStandardApp, + applicationCatalog, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts index 4326d4b554..d26b1b7b07 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts @@ -3,10 +3,9 @@ import { isNonEmptyString } from '@sniptt/guards'; import { type 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 { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; -// TODO simplify export const resolveFieldMetadataStandardOverride = ( fieldMetadata: Pick< FieldMetadataDTO, @@ -16,10 +15,11 @@ export const resolveFieldMetadataStandardOverride = ( locale: keyof typeof APP_LOCALES | undefined, i18nInstance: I18n, isStandardApp: boolean, + applicationCatalog?: Record, ): string => { const safeLocale = locale ?? SOURCE_LOCALE; - if (!isStandardApp) { + if (!isStandardApp && !isDefined(applicationCatalog)) { return fieldMetadata[labelKey] ?? ''; } @@ -43,13 +43,10 @@ export const resolveFieldMetadataStandardOverride = ( return fieldMetadata.standardOverrides[labelKey] ?? ''; } - const messageId = generateMessageId(fieldMetadata[labelKey] ?? ''); - - const translatedMessage = i18nInstance._(messageId); - - if (translatedMessage === messageId) { - return fieldMetadata[labelKey] ?? ''; - } - - return translatedMessage; + return translateStandardLabel({ + sourceValue: fieldMetadata[labelKey] ?? '', + isStandardApp, + applicationCatalog, + i18nInstance, + }); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index 581ea9bb59..c525adb460 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -46,6 +47,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceCacheStorageModule, FeatureFlagModule, ApplicationModule, + ApplicationTranslationModule, NestjsQueryGraphQLModule.forFeature({ imports: [ TypeORMModule, 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 af3e81e2f1..080dd99d41 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 @@ -9,8 +9,10 @@ import { } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -46,6 +48,7 @@ export class ObjectMetadataResolver { private readonly objectMetadataService: ObjectMetadataService, private readonly objectRecordCountService: ObjectRecordCountService, private readonly i18nService: I18nService, + private readonly applicationTranslationCacheService: ApplicationTranslationCacheService, ) {} @ResolveField(() => Boolean, { @@ -81,12 +84,30 @@ export class ObjectMetadataResolver { const standardApplicationId = await context.loaders.standardApplicationIdLoader.load({ workspaceId }); + const isStandardApp = + objectMetadata.applicationId === standardApplicationId; + + const applicationRegistrationId = isStandardApp + ? null + : await context.loaders.applicationRegistrationIdLoader.load({ + workspaceId, + applicationId: objectMetadata.applicationId, + }); + + const applicationCatalog = isDefined(applicationRegistrationId) + ? await this.applicationTranslationCacheService.getCatalog({ + applicationRegistrationId, + locale: context.req.locale, + }) + : undefined; + return resolveObjectMetadataStandardOverride( objectMetadata, labelKey, context.req.locale, i18n, - objectMetadata.applicationId === standardApplicationId, + isStandardApp, + applicationCatalog, ); } 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 index 4ee6ff2480..4d450b2998 100644 --- 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 @@ -654,4 +654,84 @@ describe('resolveObjectMetadataStandardOverride', () => { expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id'); }); }); + + describe('Application objects - catalog translations', () => { + it('should translate an application object label from its catalog', () => { + mockGenerateMessageId.mockReturnValue('app.label.id'); + + const objectMetadata = { + labelSingular: 'Property', + labelPlural: 'Properties', + description: 'A property', + icon: 'IconBuilding', + isCustom: false, + standardOverrides: undefined, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + mockI18n, + false, + { 'app.label.id': 'Bien' }, + ); + + expect(result).toBe('Bien'); + expect(mockI18n._).not.toHaveBeenCalled(); + }); + + it('should fall back to the source value when the catalog has no entry', () => { + mockGenerateMessageId.mockReturnValue('missing.id'); + + const objectMetadata = { + labelSingular: 'Property', + labelPlural: 'Properties', + description: 'A property', + icon: 'IconBuilding', + isCustom: false, + standardOverrides: undefined, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + mockI18n, + false, + {}, + ); + + expect(result).toBe('Property'); + }); + + it('should prioritize a workspace translation override over the catalog', () => { + const objectMetadata = { + labelSingular: 'Property', + labelPlural: 'Properties', + description: 'A property', + icon: 'IconBuilding', + isCustom: false, + standardOverrides: { + translations: { + 'fr-FR': { + labelSingular: 'Bien immobilier', + }, + }, + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'labelSingular', + 'fr-FR', + mockI18n, + false, + { 'app.label.id': 'Bien' }, + ); + + expect(result).toBe('Bien immobilier'); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + }); + }); }); 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 index 6d60feba3b..46b2727dc3 100644 --- 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 @@ -3,7 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { type 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 { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util'; import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; export const resolveObjectMetadataStandardOverride = ( @@ -20,10 +20,11 @@ export const resolveObjectMetadataStandardOverride = ( locale: keyof typeof APP_LOCALES | undefined, i18nInstance: I18n, isStandardApp: boolean, + applicationCatalog?: Record, ): string => { const safeLocale = locale ?? SOURCE_LOCALE; - if (!isStandardApp) { + if (!isStandardApp && !isDefined(applicationCatalog)) { return objectMetadata[labelKey] ?? ''; } @@ -51,12 +52,10 @@ export const resolveObjectMetadataStandardOverride = ( return objectMetadata.standardOverrides[labelKey] ?? ''; } - const messageId = generateMessageId(objectMetadata[labelKey] ?? ''); - const translatedMessage = i18nInstance._(messageId); - - if (translatedMessage === messageId) { - return objectMetadata[labelKey] ?? ''; - } - - return translatedMessage; + return translateStandardLabel({ + sourceValue: objectMetadata[labelKey] ?? '', + isStandardApp, + applicationCatalog, + i18nInstance, + }); }; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index c9e952e0bc..56398b7380 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -91,7 +91,7 @@ export type { DatabaseEventTriggerSettings, HttpRouteTriggerSettings, } from './logicFunctionManifestType'; -export type { Manifest } from './manifestType'; +export type { TranslationsManifest, Manifest } from './manifestType'; export type { NavigationMenuItemManifest } from './navigationMenuItemManifestType'; export type { OAuthConnectionProviderConfig } from './oauthConnectionProviderConfigType'; export type { OAuthProviderTokenRequestContentType } from './oauthProviderTokenRequestContentType.type'; diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index 2deb3ac24c..7b5ea91b72 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -1,3 +1,5 @@ +import { type AppLocale } from '@/translations'; + import { type AgentManifest } from './agentManifestType'; import { type ApplicationManifest } from './applicationType'; import { type AssetManifest } from './assetManifestType'; @@ -23,6 +25,10 @@ import { type ViewManifest, } from './viewManifestType'; +export type TranslationsManifest = Partial< + Record> +>; + export type Manifest = { application: ApplicationManifest; objects: ObjectManifest[]; @@ -42,4 +48,5 @@ export type Manifest = { pageLayouts: PageLayoutManifest[]; pageLayoutTabs: PageLayoutTabManifest[]; commandMenuItems: CommandMenuItemManifest[]; + translations?: TranslationsManifest; };