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
@@ -1,42 +1,11 @@
import { i18n, type Messages } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { Container, Html } from '@react-email/components';
import { BaseHead } from 'src/components/BaseHead';
import { Footer } from 'src/components/Footer';
import { Logo } from 'src/components/Logo';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { messages as afMessages } from '../locales/generated/af-ZA';
import { messages as arMessages } from '../locales/generated/ar-SA';
import { messages as caMessages } from '../locales/generated/ca-ES';
import { messages as csMessages } from '../locales/generated/cs-CZ';
import { messages as daMessages } from '../locales/generated/da-DK';
import { messages as deMessages } from '../locales/generated/de-DE';
import { messages as elMessages } from '../locales/generated/el-GR';
import { messages as enMessages } from '../locales/generated/en';
import { messages as esMessages } from '../locales/generated/es-ES';
import { messages as fiMessages } from '../locales/generated/fi-FI';
import { messages as frMessages } from '../locales/generated/fr-FR';
import { messages as heMessages } from '../locales/generated/he-IL';
import { messages as huMessages } from '../locales/generated/hu-HU';
import { messages as itMessages } from '../locales/generated/it-IT';
import { messages as jaMessages } from '../locales/generated/ja-JP';
import { messages as koMessages } from '../locales/generated/ko-KR';
import { messages as nlMessages } from '../locales/generated/nl-NL';
import { messages as noMessages } from '../locales/generated/no-NO';
import { messages as plMessages } from '../locales/generated/pl-PL';
import { messages as pseudoEnMessages } from '../locales/generated/pseudo-en';
import { messages as ptBRMessages } from '../locales/generated/pt-BR';
import { messages as ptPTMessages } from '../locales/generated/pt-PT';
import { messages as roMessages } from '../locales/generated/ro-RO';
import { messages as ruMessages } from '../locales/generated/ru-RU';
import { messages as srMessages } from '../locales/generated/sr-Cyrl';
import { messages as svMessages } from '../locales/generated/sv-SE';
import { messages as trMessages } from '../locales/generated/tr-TR';
import { messages as ukMessages } from '../locales/generated/uk-UA';
import { messages as viMessages } from '../locales/generated/vi-VN';
import { messages as zhHansMessages } from '../locales/generated/zh-CN';
import { messages as zhHantMessages } from '../locales/generated/zh-TW';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type BaseEmailProps = {
children: JSX.Element | JSX.Element[] | string;
@@ -44,59 +13,17 @@ type BaseEmailProps = {
locale: keyof typeof APP_LOCALES;
};
const messages: Record<keyof typeof APP_LOCALES, Messages> = {
en: enMessages,
'pseudo-en': pseudoEnMessages,
'af-ZA': afMessages,
'ar-SA': arMessages,
'ca-ES': caMessages,
'cs-CZ': csMessages,
'da-DK': daMessages,
'de-DE': deMessages,
'el-GR': elMessages,
'es-ES': esMessages,
'fi-FI': fiMessages,
'fr-FR': frMessages,
'he-IL': heMessages,
'hu-HU': huMessages,
'it-IT': itMessages,
'ja-JP': jaMessages,
'ko-KR': koMessages,
'nl-NL': nlMessages,
'no-NO': noMessages,
'pl-PL': plMessages,
'pt-BR': ptBRMessages,
'pt-PT': ptPTMessages,
'ro-RO': roMessages,
'ru-RU': ruMessages,
'sr-Cyrl': srMessages,
'sv-SE': svMessages,
'tr-TR': trMessages,
'uk-UA': ukMessages,
'vi-VN': viMessages,
'zh-CN': zhHansMessages,
'zh-TW': zhHantMessages,
};
(Object.entries(messages) as [keyof typeof APP_LOCALES, any][]).forEach(
([locale, message]) => {
i18n.load(locale, message);
},
);
i18n.activate(SOURCE_LOCALE);
export const BaseEmail = ({ children, width, locale }: BaseEmailProps) => {
i18n.activate(locale);
const i18nInstance = createI18nInstance(locale);
return (
<I18nProvider i18n={i18n}>
<I18nProvider i18n={i18nInstance}>
<Html lang={locale}>
<BaseHead />
<Container width={width || 290}>
<Logo />
{children}
<Footer />
<Footer i18n={i18nInstance} />
</Container>
</Html>
</I18nProvider>
@@ -1,4 +1,4 @@
import { i18n } from '@lingui/core';
import { type I18n } from '@lingui/core';
import { Column, Container, Row } from '@react-email/components';
import { Link } from 'src/components/Link';
import { ShadowText } from 'src/components/ShadowText';
@@ -7,7 +7,11 @@ const footerContainerStyle = {
marginTop: '12px',
};
export const Footer = () => {
type FooterProps = {
i18n: I18n;
};
export const Footer = ({ i18n }: FooterProps) => {
return (
<Container style={footerContainerStyle}>
<Row>
@@ -1,8 +1,12 @@
import { i18n } from '@lingui/core';
import { type I18n } from '@lingui/core';
import { MainText } from 'src/components/MainText';
import { SubTitle } from 'src/components/SubTitle';
export const WhatIsTwenty = () => {
type WhatIsTwentyProps = {
i18n: I18n;
};
export const WhatIsTwenty = ({ i18n }: WhatIsTwentyProps) => {
return (
<>
<SubTitle value={i18n._('What is Twenty?')} />
@@ -1,9 +1,9 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type CleanSuspendedWorkspaceEmailProps = {
@@ -19,6 +19,8 @@ export const CleanSuspendedWorkspaceEmail = ({
workspaceDisplayName,
locale,
}: CleanSuspendedWorkspaceEmailProps) => {
const i18n = createI18nInstance(locale);
return (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Deleted Workspace')} />
@@ -58,6 +60,6 @@ CleanSuspendedWorkspaceEmail.PreviewProps = {
userName: 'John Doe',
workspaceDisplayName: 'My Workspace',
locale: 'en',
} as CleanSuspendedWorkspaceEmailProps;
};
export default CleanSuspendedWorkspaceEmail;
@@ -1,10 +1,10 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { Link } from 'src/components/Link';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type PasswordResetLinkEmailProps = {
@@ -18,6 +18,8 @@ export const PasswordResetLinkEmail = ({
link,
locale,
}: PasswordResetLinkEmailProps) => {
const i18n = createI18nInstance(locale);
return (
<BaseEmail locale={locale}>
<Title value={i18n._('Reset your password 🗝')} />
@@ -1,9 +1,9 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type PasswordUpdateNotifyEmailProps = {
@@ -19,6 +19,7 @@ export const PasswordUpdateNotifyEmail = ({
link,
locale,
}: PasswordUpdateNotifyEmailProps) => {
const i18n = createI18nInstance(locale);
const formattedDate = i18n.date(new Date());
return (
@@ -1,9 +1,9 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type SendEmailVerificationLinkEmailProps = {
@@ -15,6 +15,8 @@ export const SendEmailVerificationLinkEmail = ({
link,
locale,
}: SendEmailVerificationLinkEmailProps) => {
const i18n = createI18nInstance(locale);
return (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Confirm your email address')} />
@@ -1,4 +1,3 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { Img } from '@react-email/components';
import { emailTheme } from 'src/common-style';
@@ -12,6 +11,7 @@ import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { WhatIsTwenty } from 'src/components/WhatIsTwenty';
import { capitalize } from 'src/utils/capitalize';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { getImageAbsoluteURI } from 'twenty-shared/utils';
@@ -34,6 +34,7 @@ export const SendInviteLinkEmail = ({
serverUrl,
locale,
}: SendInviteLinkEmailProps) => {
const i18n = createI18nInstance(locale);
const workspaceLogo = workspace.logo
? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl })
: null;
@@ -76,7 +77,7 @@ export const SendInviteLinkEmail = ({
{workspace.name ? <HighlightedText value={workspace.name} /> : <></>}
<CallToAction href={link} value={i18n._('Accept invite')} />
</HighlightedContainer>
<WhatIsTwenty />
<WhatIsTwenty i18n={i18n} />
</BaseEmail>
);
};
@@ -1,6 +1,6 @@
import { i18n } from '@lingui/core';
import { BaseEmail } from 'src/components/BaseEmail';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type TestEmailProps = {
@@ -10,6 +10,8 @@ type TestEmailProps = {
// This is a test email which isn't used in production
// It's useful to do tests and play in a local environment
export const TestEmail = ({ locale }: TestEmailProps) => {
const i18n = createI18nInstance(locale);
return (
<BaseEmail locale={locale}>
<Title value={i18n._('Test email')} />
@@ -21,6 +23,6 @@ export const TestEmail = ({ locale }: TestEmailProps) => {
TestEmail.PreviewProps = {
locale: 'en',
} as TestEmailProps;
};
export default TestEmail;
@@ -1,4 +1,3 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { Img } from '@react-email/components';
import { emailTheme } from 'src/common-style';
@@ -11,6 +10,7 @@ import { Link } from 'src/components/Link';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { capitalize } from 'src/utils/capitalize';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { getImageAbsoluteURI } from 'twenty-shared/utils';
import { DEFAULT_WORKSPACE_LOGO } from 'src/constants/DefaultWorkspaceLogo';
@@ -36,6 +36,7 @@ export const SendApprovedAccessDomainValidation = ({
serverUrl,
locale,
}: SendApprovedAccessDomainValidationProps) => {
const i18n = createI18nInstance(locale);
const workspaceLogo = workspace.logo
? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl })
: null;
@@ -92,6 +93,6 @@ SendApprovedAccessDomainValidation.PreviewProps = {
},
serverUrl: 'https://app.twenty.com',
locale: 'en',
} as SendApprovedAccessDomainValidationProps;
};
export default SendApprovedAccessDomainValidation;
@@ -1,9 +1,9 @@
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/react';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { createI18nInstance } from 'src/utils/i18n.utils';
import { type APP_LOCALES } from 'twenty-shared/translations';
type WarnSuspendedWorkspaceEmailProps = {
@@ -21,6 +21,7 @@ export const WarnSuspendedWorkspaceEmail = ({
workspaceDisplayName,
locale,
}: WarnSuspendedWorkspaceEmailProps) => {
const i18n = createI18nInstance(locale);
const daysLeft = inactiveDaysBeforeDelete - daysSinceInactive;
const dayOrDays = daysLeft > 1 ? 'days' : 'day';
const remainingDays = daysLeft > 0 ? daysLeft : 0;
@@ -0,0 +1,86 @@
import { setupI18n, type I18n, type Messages } from '@lingui/core';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { messages as afMessages } from '../locales/generated/af-ZA';
import { messages as arMessages } from '../locales/generated/ar-SA';
import { messages as caMessages } from '../locales/generated/ca-ES';
import { messages as csMessages } from '../locales/generated/cs-CZ';
import { messages as daMessages } from '../locales/generated/da-DK';
import { messages as deMessages } from '../locales/generated/de-DE';
import { messages as elMessages } from '../locales/generated/el-GR';
import { messages as enMessages } from '../locales/generated/en';
import { messages as esMessages } from '../locales/generated/es-ES';
import { messages as fiMessages } from '../locales/generated/fi-FI';
import { messages as frMessages } from '../locales/generated/fr-FR';
import { messages as heMessages } from '../locales/generated/he-IL';
import { messages as huMessages } from '../locales/generated/hu-HU';
import { messages as itMessages } from '../locales/generated/it-IT';
import { messages as jaMessages } from '../locales/generated/ja-JP';
import { messages as koMessages } from '../locales/generated/ko-KR';
import { messages as nlMessages } from '../locales/generated/nl-NL';
import { messages as noMessages } from '../locales/generated/no-NO';
import { messages as plMessages } from '../locales/generated/pl-PL';
import { messages as pseudoEnMessages } from '../locales/generated/pseudo-en';
import { messages as ptBRMessages } from '../locales/generated/pt-BR';
import { messages as ptPTMessages } from '../locales/generated/pt-PT';
import { messages as roMessages } from '../locales/generated/ro-RO';
import { messages as ruMessages } from '../locales/generated/ru-RU';
import { messages as srMessages } from '../locales/generated/sr-Cyrl';
import { messages as svMessages } from '../locales/generated/sv-SE';
import { messages as trMessages } from '../locales/generated/tr-TR';
import { messages as ukMessages } from '../locales/generated/uk-UA';
import { messages as viMessages } from '../locales/generated/vi-VN';
import { messages as zhHansMessages } from '../locales/generated/zh-CN';
import { messages as zhHantMessages } from '../locales/generated/zh-TW';
const messages: Record<keyof typeof APP_LOCALES, Messages> = {
en: enMessages,
'pseudo-en': pseudoEnMessages,
'af-ZA': afMessages,
'ar-SA': arMessages,
'ca-ES': caMessages,
'cs-CZ': csMessages,
'da-DK': daMessages,
'de-DE': deMessages,
'el-GR': elMessages,
'es-ES': esMessages,
'fi-FI': fiMessages,
'fr-FR': frMessages,
'he-IL': heMessages,
'hu-HU': huMessages,
'it-IT': itMessages,
'ja-JP': jaMessages,
'ko-KR': koMessages,
'nl-NL': nlMessages,
'no-NO': noMessages,
'pl-PL': plMessages,
'pt-BR': ptBRMessages,
'pt-PT': ptPTMessages,
'ro-RO': roMessages,
'ru-RU': ruMessages,
'sr-Cyrl': srMessages,
'sv-SE': svMessages,
'tr-TR': trMessages,
'uk-UA': ukMessages,
'vi-VN': viMessages,
'zh-CN': zhHansMessages,
'zh-TW': zhHantMessages,
};
const i18nInstancesMap: Partial<Record<keyof typeof APP_LOCALES, I18n>> = {};
export const createI18nInstance = (locale: keyof typeof APP_LOCALES): I18n => {
if (isDefined(i18nInstancesMap[locale])) {
return i18nInstancesMap[locale];
}
const i18nInstance = setupI18n();
const localeMessages = messages[locale] ?? messages.en;
i18nInstance.load(locale, localeMessages);
i18nInstance.activate(locale);
i18nInstancesMap[locale] = i18nInstance;
return i18nInstance;
};
@@ -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,
});
@@ -1,6 +1,5 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { i18n } from '@lingui/core';
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataType } from 'twenty-shared/types';
@@ -16,12 +15,7 @@ import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-meta
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
jest.mock('@lingui/core', () => ({
i18n: {
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
},
}));
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
// Create a type that omits id and workspaceId from UpdateFieldInput
type UpdateFieldInputForTest = Omit<UpdateFieldInput, 'id' | 'workspaceId'>;
@@ -50,6 +44,14 @@ describe('BeforeUpdateOneField', () => {
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
@@ -482,9 +484,7 @@ describe('BeforeUpdateOneField', () => {
});
it('should reset locale-specific translations when they match translated defaults', async () => {
const translatedLabel = 'translated:msg-label';
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
const translatedLabel = 'mocked-translation';
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { i18n } from '@lingui/core';
import {
type BeforeUpdateOneHook,
type UpdateOneInputType,
@@ -13,6 +12,7 @@ import {
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -30,6 +30,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
constructor(
readonly fieldMetadataService: FieldMetadataService,
readonly objectMetadataService: ObjectMetadataService,
private readonly i18nService: I18nService,
) {}
async run(
@@ -289,6 +290,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
locale: keyof typeof APP_LOCALES,
): boolean {
const messageId = generateMessageId(originalValue ?? '');
const i18n = this.i18nService.getI18nInstance(locale);
const translatedMessage = i18n._(messageId);
if (newValue !== translatedMessage) {
@@ -1,20 +1,23 @@
import { i18n } from '@lingui/core';
import { type I18n } from '@lingui/core';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util';
jest.mock('@lingui/core');
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
const mockI18n = i18n as jest.Mocked<typeof i18n>;
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
typeof generateMessageId
>;
describe('resolveFieldMetadataStandardOverride', () => {
let mockI18n: jest.Mocked<I18n>;
beforeEach(() => {
jest.clearAllMocks();
mockI18n = {
_: jest.fn(),
} as unknown as jest.Mocked<I18n>;
});
describe('Custom fields', () => {
@@ -2,7 +2,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { i18n } from '@lingui/core';
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import { type Repository } from 'typeorm';
@@ -11,12 +10,7 @@ import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-met
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
jest.mock('@lingui/core', () => ({
i18n: {
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
},
}));
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
type UpdateObjectPayloadForTest = Omit<
UpdateObjectPayload,
@@ -47,6 +41,14 @@ describe('BeforeUpdateOneObject', () => {
findBy: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
@@ -511,9 +513,7 @@ describe('BeforeUpdateOneObject', () => {
});
it('should reset locale-specific translations when they match translated defaults', async () => {
const translatedLabel = 'translated:msg-label';
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
const translatedLabel = 'mocked-translation';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
@@ -4,7 +4,6 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { i18n } from '@lingui/core';
import {
type BeforeUpdateOneHook,
type UpdateOneInputType,
@@ -13,6 +12,7 @@ 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 { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -26,7 +26,10 @@ interface StandardObjectUpdate extends Partial<UpdateObjectPayload> {
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
implements BeforeUpdateOneHook<T>
{
constructor(readonly objectMetadataService: ObjectMetadataService) {}
constructor(
readonly objectMetadataService: ObjectMetadataService,
private readonly i18nService: I18nService,
) {}
// TODO: this logic could be moved to a policy guard
async run(
@@ -220,6 +223,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
locale: keyof typeof APP_LOCALES,
): boolean {
const messageId = generateMessageId(originalValue ?? '');
const i18n = this.i18nService.getI18nInstance(locale);
const translatedMessage = i18n._(messageId);
if (newValue !== translatedMessage) {
@@ -31,6 +31,7 @@ import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-meta
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@@ -47,6 +48,7 @@ export class ObjectMetadataResolver {
private readonly beforeUpdateOneObject: BeforeUpdateOneObject<UpdateObjectPayload>,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly i18nService: I18nService,
) {}
@ResolveField(() => String, { nullable: true })
@@ -54,10 +56,13 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolveObjectMetadataStandardOverride(
objectMetadata,
'labelPlural',
context.req.locale,
i18n,
);
}
@@ -66,10 +71,13 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolveObjectMetadataStandardOverride(
objectMetadata,
'labelSingular',
context.req.locale,
i18n,
);
}
@@ -78,10 +86,13 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolveObjectMetadataStandardOverride(
objectMetadata,
'description',
context.req.locale,
i18n,
);
}
@@ -91,10 +102,13 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolveObjectMetadataStandardOverride(
objectMetadata,
'icon',
context.req.locale,
i18n,
);
}
@@ -1,21 +1,24 @@
import { i18n } from '@lingui/core';
import { type I18n } from '@lingui/core';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
jest.mock('@lingui/core');
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
const mockI18n = i18n as jest.Mocked<typeof i18n>;
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
typeof generateMessageId
>;
describe('resolveObjectMetadataStandardOverride', () => {
let mockI18n: jest.Mocked<I18n>;
beforeEach(() => {
jest.clearAllMocks();
mockI18n = {
_: jest.fn(),
} as unknown as jest.Mocked<I18n>;
});
describe('Custom objects', () => {
@@ -41,6 +44,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(result).toBe('My Custom');
@@ -68,6 +72,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'description',
undefined,
mockI18n,
);
expect(result).toBe('Custom Description');
@@ -95,6 +100,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'icon',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('custom-icon');
@@ -118,6 +124,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'icon',
'fr-FR',
mockI18n,
);
expect(result).toBe('override-icon');
@@ -148,6 +155,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
),
).toBe('Libellé traduit');
expect(
@@ -155,6 +163,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelPlural',
'fr-FR',
mockI18n,
),
).toBe('Libellés traduits');
expect(
@@ -162,6 +171,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'description',
'fr-FR',
mockI18n,
),
).toBe('Description traduite');
});
@@ -191,6 +201,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -220,6 +231,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'description',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Description');
@@ -250,6 +262,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
undefined,
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -277,6 +290,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
SOURCE_LOCALE,
mockI18n,
),
).toBe('Overridden Label');
expect(
@@ -284,6 +298,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelPlural',
SOURCE_LOCALE,
mockI18n,
),
).toBe('Overridden Labels');
expect(
@@ -291,6 +306,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'description',
SOURCE_LOCALE,
mockI18n,
),
).toBe('Overridden Description');
expect(
@@ -298,6 +314,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'icon',
SOURCE_LOCALE,
mockI18n,
),
).toBe('overridden-icon');
});
@@ -322,6 +339,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -339,10 +357,14 @@ describe('resolveObjectMetadataStandardOverride', () => {
},
};
mockGenerateMessageId.mockReturnValue('generated-message-id');
mockI18n._.mockReturnValue('generated-message-id');
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'labelSingular',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -367,6 +389,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
@@ -393,6 +416,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -423,6 +447,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'fr-FR',
mockI18n,
);
expect(result).toBe('Translation Override');
@@ -447,6 +472,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('Source Override');
@@ -471,6 +497,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
'de-DE',
mockI18n,
);
expect(result).toBe('Auto Translated Label');
@@ -496,6 +523,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
undefined,
mockI18n,
);
expect(result).toBe('Source Override');
@@ -520,6 +548,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
objectMetadata,
'labelSingular',
undefined,
mockI18n,
);
expect(result).toBe('Auto Translated Label');
@@ -1,4 +1,4 @@
import { i18n } from '@lingui/core';
import { type I18n } from '@lingui/core';
import { isNonEmptyString } from '@sniptt/guards';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
@@ -18,6 +18,7 @@ export const resolveObjectMetadataStandardOverride = (
>,
labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined,
i18nInstance: I18n,
): string => {
const safeLocale = locale ?? SOURCE_LOCALE;
@@ -52,7 +53,7 @@ export const resolveObjectMetadataStandardOverride = (
}
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
const translatedMessage = i18n._(messageId);
const translatedMessage = i18nInstance._(messageId);
if (translatedMessage === messageId) {
return objectMetadata[labelKey] ?? '';
@@ -1,8 +1,7 @@
import { Injectable, Logger } 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 { differenceInDays } from 'date-fns';
import {
@@ -16,6 +15,7 @@ import { In, Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@@ -50,6 +50,7 @@ export class CleanerWorkspaceService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(UserWorkspace)
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly i18nService: I18nService,
) {
this.inactiveDaysBeforeSoftDelete = this.twentyConfigService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
@@ -125,14 +126,16 @@ export class CleanerWorkspaceService {
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
i18n.activate(workspaceMember.locale);
const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
const subject = i18n._(workspaceDeletionMsg);
this.emailService.send({
to: workspaceMember.userEmail,
from: `${this.twentyConfigService.get(
'EMAIL_FROM_NAME',
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
subject: t`Action needed to prevent workspace deletion`,
subject,
html,
text,
});