feat(server): resolve app-owned metadata translations at runtime (#22235)

## Summary

First of a **4-PR stack** that lets apps built with `twenty-sdk`
translate their metadata, resolved at runtime. The standard Twenty app
is modelled as "an app like any other" — `NULL
applicationRegistrationId` ⟺ the standard app, no special-casing.

This PR adds the server foundation and wires runtime resolution for
**object** and **field** metadata:

- New `applicationTranslation` core table + entity (nullable
`applicationRegistrationId`, `locale`, `messages` jsonb), one row per
(app, locale) to avoid multi-MB rows.
- `ApplicationTranslationCacheService` (process-local, 30s TTL) +
`ApplicationTranslationSyncService` (upsert + soft-delete from a
manifest).
- Shared `translateStandardLabel` util: application catalog → i18n
bundle → source value.
- Object/field resolvers + dataloaders prefetch and apply the per-app
catalog. The new `applicationCatalog` param is **optional**, so standard
behaviour is byte-unchanged.
- Fast instance command to create the table.

## Stack
**PR 1/4**, targets `main`. Followed by: (2) twenty-sdk extract/compile
→ `manifest.translations`, (3) resolution across the remaining metadata
resolvers, (4) the per-locale standard-override editor.

## Tests
Unit: `translateStandardLabel`, `resolveObjectMetadataStandardOverride`
(including the application-catalog path).

## Verification note
The remote dev environment for this branch could not complete `yarn
install` (no package-registry egress), so typecheck/lint/tests were not
run locally — **CI is the source of truth** for this stack. Changes
follow existing patterns.

https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22235?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-28 07:36:21 +02:00
committed by GitHub
parent 538b180824
commit 41c10b9ee7
22 changed files with 755 additions and 37 deletions
@@ -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<void> {
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<void> {
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"`,
);
}
}
@@ -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,
];
@@ -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,
@@ -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'}`,
);
@@ -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<keyof typeof APP_LOCALES, Record<string, string>>
>;
type ApplicationTranslationCacheEntry = {
catalogsByLocale: ApplicationCatalogsByLocale;
loadedAt: number;
};
const CACHE_TTL_MS = 30_000;
const EMPTY_CATALOG: Record<string, string> = {};
@Injectable()
export class ApplicationTranslationCacheService {
private readonly cache = new Map<string, ApplicationTranslationCacheEntry>();
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<ApplicationTranslationEntity>,
) {}
async getCatalog({
applicationRegistrationId,
locale,
}: {
applicationRegistrationId: string;
locale: keyof typeof APP_LOCALES;
}): Promise<Record<string, string>> {
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<ApplicationTranslationCacheEntry> {
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;
}
}
@@ -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<ApplicationTranslationEntity>,
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
) {}
async syncFromManifest({
applicationRegistrationId,
translations,
}: {
applicationRegistrationId: string;
translations: TranslationsManifest | undefined;
}): Promise<void> {
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<keyof typeof APP_LOCALES>();
const upsertPromises: Promise<unknown>[] = [];
for (const [locale, messages] of Object.entries(translations ?? {}) as [
keyof typeof APP_LOCALES,
Record<string, string>,
][]) {
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,
);
}
}
@@ -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<ApplicationRegistrationEntity> | null;
@Column({ type: 'text' })
locale: keyof typeof APP_LOCALES;
@Column({ type: 'jsonb', default: {} })
messages: Record<string, string>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -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 {}
@@ -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<I18n>;
beforeEach(() => {
jest.clearAllMocks();
mockI18n = {
_: jest.fn(),
} as unknown as jest.Mocked<I18n>;
});
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();
});
});
@@ -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<string, string> | 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;
};
@@ -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
>;
}
@@ -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],
@@ -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<FieldMetadataLoaderPayload, FieldMetadataDTO[]>(
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<Map<string, Record<string, string>>> {
const registrationIds = [
...new Set(
applicationIds
.map(
(applicationId) =>
flatApplicationMaps.byId[applicationId]
?.applicationRegistrationId,
)
.filter(isDefined),
),
];
const catalogByRegistrationId = new Map<string, Record<string, string>>();
await Promise.all(
registrationIds.map(async (applicationRegistrationId) => {
const catalog =
await this.applicationTranslationCacheService.getCatalog({
applicationRegistrationId,
locale,
});
catalogByRegistrationId.set(applicationRegistrationId, catalog);
}),
);
return catalogByRegistrationId;
}
}
@@ -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,
@@ -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,
);
}
@@ -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, string>,
): 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,
});
};
@@ -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,
@@ -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,
);
}
@@ -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();
});
});
});
@@ -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, string>,
): 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,
});
};
@@ -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';
@@ -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<AppLocale, Record<string, string>>
>;
export type Manifest = {
application: ApplicationManifest;
objects: ObjectManifest[];
@@ -42,4 +48,5 @@ export type Manifest = {
pageLayouts: PageLayoutManifest[];
pageLayoutTabs: PageLayoutTabManifest[];
commandMenuItems: CommandMenuItemManifest[];
translations?: TranslationsManifest;
};