From 1df00698cf367162609ae6635a802db0a990dcbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 30 Jun 2026 23:08:38 +0200 Subject: [PATCH] feat(server): make workspace Custom application carry an applicationRegistration so custom labels are translatable (#22378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Custom objects/fields belong to a per-workspace **Custom** application (`workspace.workspaceCustomApplicationId`). That application was created with `applicationRegistrationId = null`. Because the metadata label resolver loads a translation catalog from `core.applicationTranslation` **keyed by `applicationRegistrationId`** (`ApplicationTranslationCacheService.getCatalog` → `applicationTranslationCatalogLoader` → `resolveObjectMetadataStandardOverride` / `resolveFieldMetadataStandardOverride`), the Custom app had no catalog and custom labels always resolved to the raw source string. This is the foundational slice: it wires up the missing key so custom labels can be translated **exactly like any installed third-party app**. The read/resolve path already works once a catalog exists — confirmed end-to-end. `flatApplicationMaps` carries `applicationRegistrationId` straight from the entity column, so setting it + recomputing that cache is all that's needed. ## What changed - **`ApplicationService.createWorkspaceCustomApplication`** now creates a workspace-scoped `applicationRegistration` and links it to the Custom application. This covers both production creation sites (sign-in-up and the dev-seeder), which are the only callers. - **New idempotent workspace upgrade command** `upgrade:2-18:backfill-workspace-custom-application-registration` creates a registration for each existing workspace's Custom application that lacks one and links it. It delegates the registration lifecycle (create + link + `flatApplicationMaps` recompute) to `ApplicationService`, so the command only decides *which* workspaces need it. - New `WORKSPACE_CUSTOM_APPLICATION_NAME` constant; the registration creation lives in `ApplicationService.createWorkspaceCustomApplicationRegistration`. ## Design decisions - **Per-workspace registration (not a shared "custom" registration).** `applicationTranslation` is keyed *only* by `applicationRegistrationId` (cross-workspace). A shared registration would force every workspace's custom translations into one catalog keyed by `generateMessageId(sourceText)`, guaranteeing cross-workspace collisions and leakage (two workspaces both naming an object "Project" would clash). Each workspace's Custom app gets its own registration (`ownerWorkspaceId = workspaceId`, `universalIdentifier = the Custom app's per-workspace uuid`) and thus an isolated catalog — matching installed-app behaviour, where `application.universalIdentifier === registration.universalIdentifier`. - **Source-label keying kept** (`generateMessageId(sourceLabel)`). The resolve path and the third-party manifest pipeline both key catalogs this way. Re-keying by a stable `universalIdentifier` would require changing the shared resolver/dataloader for *all* apps and would break marketplace manifest translations — out of scope for this slice. Consequence: renaming a label orphans its catalog entry (it falls back to the source label until re-translated) — the same behaviour an installed app has when it changes a source string. Re-keying on rename can be handled later by the interactive write path. - **Workspace command (not instance command)** for the backfill: it is per-workspace data logic that must recompute the per-workspace `flatApplicationMaps` cache the resolver reads from. It is idempotent (skips Custom apps that already have a registration), supports `--dry-run`, and is forward-only by design. - **Interactive write path deferred** as an explicit follow-up. This slice proves the read/resolve path; an editor that writes custom translations into `applicationTranslation` (+ cache invalidation) is the natural next step. ## Tests - **Unit test** for the backfill command: creation + linking, idempotency, dry-run, and the skip paths. - **Integration test** (`custom-application-translation.integration-spec.ts`): on a freshly created workspace (so the registration's translation cache is guaranteed cold), it asserts the Custom application is created with a registration, seeds an `applicationTranslation` row, and verifies a custom object's label resolves from that catalog while a label with no catalog entry falls back to its source label. ## Notes for reviewers - No new entity columns or migrations beyond the workspace command — `ApplicationRegistrationEntity` already supports a workspace-scoped `workspaceId`. - The backfill follows the established upgrade-command pattern: it imports `ApplicationModule` and delegates to `ApplicationService` (consistent with the other version-command modules). https://claude.ai/code/session_018heTgu4ew4AJ99VVz4bjqd --- _Generated by [Claude Code](https://claude.ai/code/session_018heTgu4ew4AJ99VVz4bjqd)_ Review in cubic --- .../2-19-upgrade-version-command.module.ts | 18 ++ ...custom-application-registration.command.ts | 99 +++++++++ ...m-application-registration.command.spec.ts | 135 ++++++++++++ .../workspace-command-provider.module.ts | 2 + .../application/application.module.ts | 2 + .../application/application.service.ts | 51 ++++- .../workspace-custom-application.constant.ts | 1 + ...pplication-translation.integration-spec.ts | 204 ++++++++++++++++++ 8 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-19/__tests__/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/constants/workspace-custom-application.constant.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/workspace/custom-application-translation.integration-spec.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module.ts new file mode 100644 index 0000000000..3844837d7a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { BackfillWorkspaceCustomApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Module({ + imports: [ + ApplicationModule, + TypeOrmModule.forFeature([WorkspaceEntity, ApplicationEntity]), + WorkspaceIteratorModule, + ], + providers: [BackfillWorkspaceCustomApplicationRegistrationCommand], +}) +export class V2_19_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.ts new file mode 100644 index 0000000000..b9722af2cf --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.ts @@ -0,0 +1,99 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; + +@RegisteredWorkspaceCommand('2.19.0', 1820000000000) +@Command({ + name: 'upgrade:2-19:backfill-workspace-custom-application-registration', + description: + 'Create a workspace-scoped application registration for each existing workspace Custom application so custom object/field labels become translatable.', +}) +export class BackfillWorkspaceCustomApplicationRegistrationCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + private readonly applicationService: ApplicationService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + // Suspended workspaces are soft-deleted, so the row needs withDeleted. + const workspace = await this.workspaceRepository.findOne({ + select: ['id', 'workspaceCustomApplicationId'], + where: { id: workspaceId }, + withDeleted: true, + }); + + if (!isDefined(workspace)) { + this.logger.log(`Workspace ${workspaceId} not found, skipping`); + + return; + } + + const customApplication = await this.applicationRepository.findOne({ + select: ['id', 'universalIdentifier', 'applicationRegistrationId'], + where: { + id: workspace.workspaceCustomApplicationId, + workspaceId, + }, + }); + + if (!isDefined(customApplication)) { + this.logger.log( + `No custom application for workspace ${workspaceId}, skipping`, + ); + + return; + } + + if (isDefined(customApplication.applicationRegistrationId)) { + this.logger.log( + `Custom application for workspace ${workspaceId} already has a registration, skipping`, + ); + + return; + } + + if (options.dryRun) { + this.logger.log( + `[DRY RUN] Would create an application registration for workspace ${workspaceId} custom application`, + ); + + return; + } + + const registration = + await this.applicationService.createWorkspaceCustomApplicationRegistration( + { + workspaceId, + universalIdentifier: customApplication.universalIdentifier, + }, + ); + + await this.applicationService.update(customApplication.id, { + applicationRegistrationId: registration.id, + workspaceId, + }); + + this.logger.log( + `Created application registration ${registration.id} for workspace ${workspaceId} custom application`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/__tests__/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/__tests__/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.spec.ts new file mode 100644 index 0000000000..12d37b736d --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/__tests__/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command.spec.ts @@ -0,0 +1,135 @@ +import { type Repository } from 'typeorm'; + +import { type WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { BackfillWorkspaceCustomApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-workspace-command-1820000000000-backfill-workspace-custom-application-registration.command'; +import { type ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +const WORKSPACE_ID = '20202020-0000-0000-0000-000000000001'; +const CUSTOM_APPLICATION_ID = '20202020-0000-0000-0000-0000000000a1'; + +describe('BackfillWorkspaceCustomApplicationRegistrationCommand', () => { + let command: BackfillWorkspaceCustomApplicationRegistrationCommand; + let workspaceFindOne: jest.Mock; + let applicationFindOne: jest.Mock; + let createRegistration: jest.Mock; + let updateApplication: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + workspaceFindOne = jest.fn(); + applicationFindOne = jest.fn(); + createRegistration = jest.fn().mockResolvedValue({ id: 'registration-1' }); + updateApplication = jest.fn(); + + const workspaceRepository = { + findOne: workspaceFindOne, + } as unknown as Repository; + const applicationRepository = { + findOne: applicationFindOne, + } as unknown as Repository; + const applicationService = { + createWorkspaceCustomApplicationRegistration: createRegistration, + update: updateApplication, + } as unknown as ApplicationService; + + command = new BackfillWorkspaceCustomApplicationRegistrationCommand( + {} as WorkspaceIteratorService, + workspaceRepository, + applicationRepository, + applicationService, + ); + + jest.spyOn(command['logger'], 'log').mockImplementation(); + }); + + const runOnWorkspace = (dryRun = false) => + command.runOnWorkspace({ + workspaceId: WORKSPACE_ID, + options: { dryRun }, + index: 0, + total: 1, + }); + + it('creates a registration through ApplicationService and links it to the custom application', async () => { + workspaceFindOne.mockResolvedValue({ + id: WORKSPACE_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + applicationFindOne.mockResolvedValue({ + id: CUSTOM_APPLICATION_ID, + universalIdentifier: CUSTOM_APPLICATION_ID, + applicationRegistrationId: null, + }); + + await runOnWorkspace(); + + expect(createRegistration).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + universalIdentifier: CUSTOM_APPLICATION_ID, + }); + expect(updateApplication).toHaveBeenCalledWith(CUSTOM_APPLICATION_ID, { + applicationRegistrationId: 'registration-1', + workspaceId: WORKSPACE_ID, + }); + }); + + it('is idempotent: skips when the custom application already has a registration', async () => { + workspaceFindOne.mockResolvedValue({ + id: WORKSPACE_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + applicationFindOne.mockResolvedValue({ + id: CUSTOM_APPLICATION_ID, + universalIdentifier: CUSTOM_APPLICATION_ID, + applicationRegistrationId: 'existing-registration', + }); + + await runOnWorkspace(); + + expect(createRegistration).not.toHaveBeenCalled(); + expect(updateApplication).not.toHaveBeenCalled(); + }); + + it('does not write anything in dry-run mode', async () => { + workspaceFindOne.mockResolvedValue({ + id: WORKSPACE_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + applicationFindOne.mockResolvedValue({ + id: CUSTOM_APPLICATION_ID, + universalIdentifier: CUSTOM_APPLICATION_ID, + applicationRegistrationId: null, + }); + + await runOnWorkspace(true); + + expect(createRegistration).not.toHaveBeenCalled(); + expect(updateApplication).not.toHaveBeenCalled(); + }); + + it('skips when the workspace row cannot be found', async () => { + workspaceFindOne.mockResolvedValue(null); + + await runOnWorkspace(); + + expect(applicationFindOne).not.toHaveBeenCalled(); + expect(createRegistration).not.toHaveBeenCalled(); + expect(updateApplication).not.toHaveBeenCalled(); + }); + + it('skips when the custom application row cannot be found', async () => { + workspaceFindOne.mockResolvedValue({ + id: WORKSPACE_ID, + workspaceCustomApplicationId: CUSTOM_APPLICATION_ID, + }); + applicationFindOne.mockResolvedValue(null); + + await runOnWorkspace(); + + expect(createRegistration).not.toHaveBeenCalled(); + expect(updateApplication).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 852da026fe..3e6f0c0066 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -19,6 +19,7 @@ import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V2_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module'; import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module'; import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module'; +import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade V2_16_UpgradeVersionCommandModule, V2_17_UpgradeVersionCommandModule, V2_18_UpgradeVersionCommandModule, + V2_19_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index 3be9c01fd1..72e8d66cba 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/workspace-flat-application-map-cache.service'; @@ -21,6 +22,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache imports: [ TypeOrmModule.forFeature([ ApplicationEntity, + ApplicationRegistrationEntity, WorkspaceEntity, LogicFunctionEntity, AgentEntity, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index fa68bc73fd..0dd1e04bf3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type QueryRunner, type Repository } from 'typeorm'; +import { v4 } from 'uuid'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { @@ -12,7 +13,10 @@ import { } from 'src/engine/core-modules/application/application.exception'; import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/application-package/utils/get-default-application-package-fields.util'; import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; +import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -33,6 +37,8 @@ export class ApplicationService { constructor( @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, + @InjectRepository(ApplicationRegistrationEntity) + private readonly applicationRegistrationRepository: Repository, private readonly workspaceCacheService: WorkspaceCacheService, private readonly fileStorageService: FileStorageService, @InjectRepository(WorkspaceEntity) @@ -387,15 +393,25 @@ export class ApplicationService { ) { const defaultPackageFields = await getDefaultApplicationPackageFields(); + const applicationRegistration = + await this.createWorkspaceCustomApplicationRegistration( + { + workspaceId, + universalIdentifier: applicationId, + }, + queryRunner, + ); + const workspaceCustomApplication = await this.create( { description: null, - name: 'Custom', + name: WORKSPACE_CUSTOM_APPLICATION_NAME, sourcePath: 'workspace-custom', version: '1.0.1', universalIdentifier: applicationId, workspaceId, id: applicationId, + applicationRegistrationId: applicationRegistration.id, logicFunctionLayerId: null, canBeUninstalled: false, packageJsonChecksum: defaultPackageFields.packageJsonChecksum, @@ -415,6 +431,39 @@ export class ApplicationService { return workspaceCustomApplication; } + async createWorkspaceCustomApplicationRegistration( + { + workspaceId, + universalIdentifier, + }: { + workspaceId: string; + universalIdentifier: string; + }, + queryRunner?: QueryRunner, + ): Promise { + const applicationRegistration = + this.applicationRegistrationRepository.create({ + universalIdentifier, + name: WORKSPACE_CUSTOM_APPLICATION_NAME, + oAuthClientId: v4(), + oAuthClientSecretHash: null, + oAuthRedirectUris: [], + oAuthScopes: [], + ownerWorkspaceId: workspaceId, + sourceType: ApplicationRegistrationSourceType.LOCAL, + createdByUserId: null, + }); + + if (queryRunner) { + return queryRunner.manager.save( + ApplicationRegistrationEntity, + applicationRegistration, + ); + } + + return this.applicationRegistrationRepository.save(applicationRegistration); + } + async uploadDefaultPackageFilesAndSetFileIds( application: Pick< ApplicationEntity, diff --git a/packages/twenty-server/src/engine/core-modules/application/constants/workspace-custom-application.constant.ts b/packages/twenty-server/src/engine/core-modules/application/constants/workspace-custom-application.constant.ts new file mode 100644 index 0000000000..09295b8705 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/constants/workspace-custom-application.constant.ts @@ -0,0 +1 @@ +export const WORKSPACE_CUSTOM_APPLICATION_NAME = 'Custom'; diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/custom-application-translation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/custom-application-translation.integration-spec.ts new file mode 100644 index 0000000000..5d19ef8d2d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/custom-application-translation.integration-spec.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'crypto'; + +import request from 'supertest'; +import { activateWorkspace } from 'test/integration/graphql/utils/activate-workspace.util'; +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; +import { signUp } from 'test/integration/graphql/utils/sign-up.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; + +const client = request(`http://localhost:${APP_PORT}`); + +const SOURCE_LABEL_SINGULAR = 'My Robot'; +const SOURCE_LABEL_PLURAL = 'My Robots'; +const SOURCE_DESCRIPTION = 'A friendly robot'; + +const TRANSLATED_LABEL_SINGULAR = 'Translated Robot'; +const TRANSLATED_LABEL_PLURAL = 'Translated Robots'; +const TRANSLATED_DESCRIPTION = 'Translated robot description'; + +const CONTROL_LABEL_SINGULAR = 'My Gadget'; +const CONTROL_LABEL_PLURAL = 'My Gadgets'; + +type ObjectNode = { + nameSingular: string; + labelSingular: string; + labelPlural: string; + description: string; +}; + +const queryObjects = (accessToken: string) => + client + .post('/metadata') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + query CustomObjectsI18n { + objects(paging: { first: 200 }) { + edges { + node { + nameSingular + labelSingular + labelPlural + description + } + } + } + } + `, + }); + +const findObjectByName = ( + edges: Array<{ node: ObjectNode }>, + nameSingular: string, +): ObjectNode | undefined => + edges.find((edge) => edge.node.nameSingular === nameSingular)?.node; + +describe('custom application translation resolve path', () => { + let createdUserAccessToken: string | undefined; + + afterEach(async () => { + if (!isDefined(createdUserAccessToken)) { + return; + } + + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + createdUserAccessToken = undefined; + }); + + it('translates a custom object label from the application translation catalog, and falls back to the source label otherwise', async () => { + // A fresh workspace guarantees its Custom application registration has never + // had its translation catalog loaded, so the seeded row below is read + // straight from the database rather than from a warm (empty) cache. + const uniqueEmail = `test-custom-translation-${randomUUID()}@example.com`; + + const { data: signUpData } = await signUp({ + input: { email: uniqueEmail, password: 'Test123!@#' }, + expectToFail: false, + }); + + createdUserAccessToken = + signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + await testDataSource.query( + 'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1', + [uniqueEmail], + ); + + const { + data: { signUpInNewWorkspace: signUpInNewWorkspaceData }, + } = await signUpInNewWorkspace({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + const { + data: { getAuthTokensFromLoginToken: authTokensData }, + } = await getAuthTokensFromLoginToken({ + origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl, + loginToken: signUpInNewWorkspaceData.loginToken.token, + expectToFail: false, + }); + + const workspaceAccessToken = + authTokensData.tokens.accessOrWorkspaceAgnosticToken.token; + + await activateWorkspace({ + accessToken: workspaceAccessToken, + expectToFail: false, + }); + + const { + data: { currentUser }, + } = await getCurrentUser({ + accessToken: workspaceAccessToken, + expectToFail: false, + }); + + jestExpectToBeDefined(currentUser.currentWorkspace); + const workspaceId = currentUser.currentWorkspace.id; + const workspaceCustomApplicationId = + currentUser.currentWorkspace.workspaceCustomApplicationId; + + const [customApplicationRow] = await testDataSource.query( + `SELECT "applicationRegistrationId" + FROM core.application + WHERE id = $1 AND "workspaceId" = $2`, + [workspaceCustomApplicationId, workspaceId], + ); + + jestExpectToBeDefined(customApplicationRow); + const applicationRegistrationId = + customApplicationRow.applicationRegistrationId; + + expect(applicationRegistrationId).toEqual(expect.any(String)); + + const messages = { + [generateMessageId(SOURCE_LABEL_SINGULAR)]: TRANSLATED_LABEL_SINGULAR, + [generateMessageId(SOURCE_LABEL_PLURAL)]: TRANSLATED_LABEL_PLURAL, + [generateMessageId(SOURCE_DESCRIPTION)]: TRANSLATED_DESCRIPTION, + }; + + await testDataSource.query( + `INSERT INTO core."applicationTranslation" + ("applicationRegistrationId", locale, messages) + VALUES ($1, $2, $3::jsonb)`, + [applicationRegistrationId, SOURCE_LOCALE, JSON.stringify(messages)], + ); + + await createOneObjectMetadata({ + input: { + nameSingular: 'myRobot', + namePlural: 'myRobots', + labelSingular: SOURCE_LABEL_SINGULAR, + labelPlural: SOURCE_LABEL_PLURAL, + description: SOURCE_DESCRIPTION, + isLabelSyncedWithName: false, + }, + token: workspaceAccessToken, + expectToFail: false, + }); + + await createOneObjectMetadata({ + input: { + nameSingular: 'myGadget', + namePlural: 'myGadgets', + labelSingular: CONTROL_LABEL_SINGULAR, + labelPlural: CONTROL_LABEL_PLURAL, + isLabelSyncedWithName: false, + }, + token: workspaceAccessToken, + expectToFail: false, + }); + + const response = await queryObjects(workspaceAccessToken); + + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.objects.edges; + + const myRobot = findObjectByName(edges, 'myRobot'); + + jestExpectToBeDefined(myRobot); + expect(myRobot.labelSingular).toBe(TRANSLATED_LABEL_SINGULAR); + expect(myRobot.labelPlural).toBe(TRANSLATED_LABEL_PLURAL); + expect(myRobot.description).toBe(TRANSLATED_DESCRIPTION); + + const myGadget = findObjectByName(edges, 'myGadget'); + + jestExpectToBeDefined(myGadget); + expect(myGadget.labelSingular).toBe(CONTROL_LABEL_SINGULAR); + expect(myGadget.labelPlural).toBe(CONTROL_LABEL_PLURAL); + }); +});