remove singleton usage of i18n (#14422)

closes https://github.com/twentyhq/core-team-issues/issues/1480

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-09-17 22:31:43 +05:30
committed by GitHub
parent 2ffbe017c2
commit 666b99524a
34 changed files with 286 additions and 186 deletions
@@ -249,6 +249,7 @@ describe('ApprovedAccessDomainService', () => {
const sender = {
userEmail: 'sender@example.com',
name: { firstName: 'John', lastName: 'Doe' },
locale: 'en',
} as WorkspaceMemberWorkspaceEntity;
const workspace = {
displayName: 'Test Workspace',
@@ -26,6 +26,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { AuthService } from './auth.service';
@@ -134,6 +135,14 @@ describe('AuthService', () => {
findWorkspaceFromWorkspaceIdOrAuthProvider: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'node:crypto';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { t, msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -54,6 +53,7 @@ import { type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@@ -87,6 +87,7 @@ export class AuthService {
private readonly emailService: EmailService,
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository<AppToken>,
private readonly i18nService: I18nService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -485,14 +486,16 @@ export class AuthService {
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
i18n.activate(firstUserWorkspace.locale);
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
const subject = i18n._(passwordChangedMsg);
await this.emailService.send({
from: `${this.twentyConfigService.get(
'EMAIL_FROM_NAME',
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
to: user.email,
subject: t`Your Password Has Been Successfully Changed`,
subject,
text,
html,
});
@@ -14,6 +14,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ResetPasswordService } from './reset-password.service';
@@ -67,6 +68,14 @@ describe('ResetPasswordService', () => {
get: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -28,6 +27,7 @@ import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.
import { type ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -45,6 +45,7 @@ export class ResetPasswordService {
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository<AppToken>,
private readonly emailService: EmailService,
private readonly i18nService: I18nService,
) {}
async generatePasswordResetToken(
@@ -165,14 +166,16 @@ export class ResetPasswordService {
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
i18n.activate(locale);
const resetPasswordMsg = msg`Action Needed to Reset Password`;
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(resetPasswordMsg);
this.emailService.send({
from: `${this.twentyConfigService.get(
'EMAIL_FROM_NAME',
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: t`Action Needed to Reset Password`,
subject,
text,
html,
});
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -24,6 +23,7 @@ import {
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@@ -37,6 +37,7 @@ export class EmailVerificationService {
private readonly twentyConfigService: TwentyConfigService,
private readonly userService: UserService,
private readonly emailVerificationTokenService: EmailVerificationTokenService,
private readonly i18nService: I18nService,
) {}
async sendVerificationEmail(
@@ -84,13 +85,16 @@ export class EmailVerificationService {
plainText: true,
});
i18n.activate(locale);
const emailVerificationMsg = msg`Welcome to Twenty: Please Confirm Your Email`;
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(emailVerificationMsg);
await this.emailService.send({
from: `${this.twentyConfigService.get(
'EMAIL_FROM_NAME',
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: t`Welcome to Twenty: Please Confirm Your Email`,
subject,
text,
html,
});
@@ -1,21 +0,0 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import { i18n } from '@lingui/core';
import { type NextFunction, type Request, type Response } from 'express';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
// TODO: this should be deprecated as singleton pattern won't work: user will keep changing locales for eachothers
@Injectable()
export class I18nMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const locale = req.locale;
if (locale && Object.values(APP_LOCALES).includes(locale)) {
i18n.activate(locale);
} else {
i18n.activate(SOURCE_LOCALE);
}
next();
}
}
@@ -1,11 +1,5 @@
import {
Global,
type MiddlewareConsumer,
Module,
type NestModule,
} from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { I18nMiddleware } from 'src/engine/core-modules/i18n/i18n.middleware';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@Global()
@@ -13,8 +7,4 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(I18nMiddleware).forRoutes('*');
}
}
export class I18nModule {}
@@ -4,7 +4,6 @@ import {
type I18n,
type MessageOptions,
type Messages,
i18n,
setupI18n,
} from '@lingui/core';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -90,14 +89,7 @@ export class I18nService implements OnModuleInit {
localeI18n.activate(locale);
this.i18nInstancesMap[locale] = localeI18n;
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
// Also deprecate i18n.middleware.ts
i18n.load(locale, messages);
});
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
i18n.activate(SOURCE_LOCALE);
}
getI18nInstance(locale: keyof typeof APP_LOCALES) {
@@ -31,6 +31,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
@Controller('rest/metadata/views')
@@ -40,6 +41,7 @@ export class ViewController {
constructor(
private readonly viewService: ViewService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly i18nService: I18nService,
) {}
@Get()
@@ -163,6 +165,7 @@ export class ViewController {
const objectMetadata = objectMetadataMaps.byId[view.objectMetadataId];
if (objectMetadata) {
const i18n = this.i18nService.getI18nInstance(locale ?? 'en');
const translatedObjectLabel = resolveObjectMetadataStandardOverride(
{
labelPlural: objectMetadata.labelPlural,
@@ -174,6 +177,7 @@ export class ViewController {
},
'labelPlural',
locale,
i18n,
);
processedName = this.viewService.processViewNameWithTemplate(
@@ -36,6 +36,7 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@Resolver(() => ViewDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@@ -48,6 +49,7 @@ export class ViewResolver {
private readonly viewFilterGroupService: ViewFilterGroupService,
private readonly viewSortService: ViewSortService,
private readonly viewGroupService: ViewGroupService,
private readonly i18nService: I18nService,
private readonly featureFlagService: FeatureFlagService,
private readonly viewV2Service: ViewV2Service,
) {}
@@ -65,6 +67,7 @@ export class ViewResolver {
});
if (objectMetadata) {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
const translatedObjectLabel = resolveObjectMetadataStandardOverride(
{
labelPlural: objectMetadata.labelPlural,
@@ -76,6 +79,7 @@ export class ViewResolver {
},
'labelPlural',
context.req.locale,
i18n,
);
return this.viewService.processViewNameWithTemplate(
@@ -16,6 +16,7 @@ import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { WorkspaceInvitationService } from './workspace-invitation.service';
@@ -83,6 +84,14 @@ describe('WorkspaceInvitationService', () => {
// Add other methods as needed
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
@@ -152,6 +161,7 @@ describe('WorkspaceInvitationService', () => {
const sender = {
userEmail: 'sender@example.com',
name: { firstName: 'Sender' },
locale: 'en',
};
jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
@@ -23,6 +22,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -46,6 +46,7 @@ export class WorkspaceInvitationService {
private readonly emailService: EmailService,
private readonly onboardingService: OnboardingService,
private readonly domainManagerService: DomainManagerService,
private readonly i18nService: I18nService,
) {}
async validatePersonalInvitation({
@@ -309,12 +310,14 @@ export class WorkspaceInvitationService {
plainText: true,
});
i18n.activate(sender.locale);
const joinTeamMsg = msg`Join your team on Twenty`;
const i18n = this.i18nService.getI18nInstance(sender.locale);
const subject = i18n._(joinTeamMsg);
await this.emailService.send({
from: `${sender.name.firstName} ${sender.name.lastName} (via Twenty) <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
to: invitation.value.email,
subject: t`Join your team on Twenty`,
subject,
text,
html,
});