Rework locale computation on BE (#13247)

Context:

Users are complaining to see their workspace in a language they don't
know. This behavior is transient, happens on data model update and
disappear on refresh
I've check the cache for users that got the issue and did not spot any
weird language
==> I think we somehow fallback the the request header locale. I feel we
should always use the userWorkspace.locale, request locale should not be
used in BE in my opinion except for unauthenticated endpoints. I'm also
adding logs to understand the locale issue
In this PR:

rename user.workspaces into user.userWorkspaces which is more correct
improve / simplify LOCALES typing
This commit is contained in:
Charles Bochet
2025-07-16 18:51:46 +02:00
committed by GitHub
parent 7fde4944d8
commit b25f50e288
20 changed files with 119 additions and 78 deletions

View File

@ -2727,6 +2727,7 @@ export type User = {
supportUserHash?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
userVars?: Maybe<Scalars['JSONObject']>;
userWorkspaces: Array<UserWorkspace>;
workspaceMember?: Maybe<WorkspaceMember>;
workspaceMembers?: Maybe<Array<WorkspaceMember>>;
workspaces: Array<UserWorkspace>;

View File

@ -2565,6 +2565,7 @@ export type User = {
supportUserHash?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
userVars?: Maybe<Scalars['JSONObject']>;
userWorkspaces: Array<UserWorkspace>;
workspaceMember?: Maybe<WorkspaceMember>;
workspaceMembers?: Maybe<Array<WorkspaceMember>>;
workspaces: Array<UserWorkspace>;

View File

@ -49,7 +49,7 @@ export class LowercaseUserAndInvitationEmailsCommand extends ActiveOrSuspendedWo
private async lowercaseUserEmails(workspaceId: string, dryRun: boolean) {
const users = await this.userRepository.find({
where: {
workspaces: {
userWorkspaces: {
workspaceId,
},
email: Raw((alias) => `LOWER(${alias}) != ${alias}`),

View File

@ -1,7 +1,10 @@
import { createHash } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { Request } from 'express';
import { Plugin } from 'graphql-yoga';
import { isDefined } from 'twenty-shared/utils';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export type CacheMetadataPluginConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -12,19 +15,26 @@ export type CacheMetadataPluginConfig = {
};
export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const computeCacheKey = (serverContext: any) => {
const workspaceId = serverContext.req.workspace?.id ?? 'anonymous';
const workspaceMetadataVersion =
serverContext.req.workspaceMetadataVersion ?? '0';
const operationName = getOperationName(serverContext);
const locale = serverContext.req.locale;
const localeCacheKey = isNonEmptyString(locale) ? `:${locale}` : '';
const computeCacheKey = ({
operationName,
request,
}: {
operationName: string;
request: Pick<Request, 'workspace' | 'locale' | 'body'>;
}) => {
const workspace = request.workspace;
if (!isDefined(workspace)) {
throw new InternalServerError('Workspace is not defined');
}
const workspaceMetadataVersion = workspace.metadataVersion ?? '0';
const locale = request.locale;
const queryHash = createHash('sha256')
.update(serverContext.req.body.query)
.update(request.body.query)
.digest('hex');
return `graphql:operations:${operationName}:${workspaceId}:${workspaceMetadataVersion}${localeCacheKey}:${queryHash}`;
return `graphql:operations:${operationName}:${workspace.id}:${workspaceMetadataVersion}:${locale}:${queryHash}`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -37,7 +47,11 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
return;
}
const cacheKey = computeCacheKey(serverContext);
const cacheKey = computeCacheKey({
operationName: getOperationName(serverContext),
// TODO: we should probably override the graphql-yoga request type to include the workspace and locale
request: (serverContext as unknown as { req: Request }).req,
});
const cachedResponse = await config.cacheGetter(cacheKey);
if (cachedResponse) {
@ -51,7 +65,10 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
return;
}
const cacheKey = computeCacheKey(serverContext);
const cacheKey = computeCacheKey({
operationName: getOperationName(serverContext),
request: (serverContext as unknown as { req: Request }).req,
});
const cachedResponse = await config.cacheGetter(cacheKey);

View File

@ -91,7 +91,7 @@ describe('AdminPanelService', () => {
const mockUser = {
id: 'user-id',
email: 'user@example.com',
workspaces: [
userWorkspaces: [
{
workspace: {
id: 'workspace-id',
@ -114,12 +114,12 @@ describe('AdminPanelService', () => {
expect.objectContaining({
where: expect.objectContaining({
id: 'user-id',
workspaces: {
userWorkspaces: {
workspaceId: 'workspace-id',
workspace: { allowImpersonation: true },
},
}),
relations: ['workspaces', 'workspaces.workspace'],
relations: { userWorkspaces: { workspace: true } },
}),
);

View File

@ -39,14 +39,14 @@ export class AdminPanelService {
const user = await this.userRepository.findOne({
where: {
id: userId,
workspaces: {
userWorkspaces: {
workspaceId,
workspace: {
allowImpersonation: true,
},
},
},
relations: ['workspaces', 'workspaces.workspace'],
relations: { userWorkspaces: { workspace: true } },
});
userValidator.assertIsDefinedOrThrow(
@ -59,14 +59,14 @@ export class AdminPanelService {
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
user.workspaces[0].workspace.id,
user.userWorkspaces[0].workspace.id,
);
return {
workspace: {
id: user.workspaces[0].workspace.id,
id: user.userWorkspaces[0].workspace.id,
workspaceUrls: this.domainManagerService.getWorkspaceUrls(
user.workspaces[0].workspace,
user.userWorkspaces[0].workspace,
),
},
loginToken,
@ -101,7 +101,7 @@ export class AdminPanelService {
firstName: targetUser.firstName,
lastName: targetUser.lastName,
},
workspaces: targetUser.workspaces.map((userWorkspace) => ({
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length,

View File

@ -6,7 +6,6 @@ import crypto from 'crypto';
import { t } from '@lingui/core/macro';
import { render } from '@react-email/render';
import { SendApprovedAccessDomainValidation } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared/translations';
import { Repository } from 'typeorm';
import { ApprovedAccessDomain as ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
@ -78,7 +77,7 @@ export class ApprovedAccessDomainService {
lastName: sender.name.lastName,
},
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: 'en' as keyof typeof APP_LOCALES,
locale: 'en',
});
const html = await render(emailTemplate);
const text = await render(emailTemplate, {

View File

@ -528,14 +528,13 @@ export class AuthResolver {
async updatePasswordViaResetToken(
@Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
@Context() context: I18nContext,
): Promise<InvalidatePassword> {
const { id } =
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
);
await this.authService.updatePassword(id, newPassword, context.req.locale);
await this.authService.updatePassword(id, newPassword);
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}

View File

@ -115,7 +115,7 @@ export class SSOAuthController {
const workspaceIdentityProvider =
await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: req.user.identityProviderId },
relations: ['workspace'],
relations: { workspace: true },
});
try {

View File

@ -9,7 +9,6 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@ -142,7 +141,7 @@ export class AuthService {
where: {
email: input.email,
},
relations: ['workspaces'],
relations: { userWorkspaces: true },
});
if (!user) {
@ -424,7 +423,6 @@ export class AuthService {
async updatePassword(
userId: string,
newPassword: string,
locale: keyof typeof APP_LOCALES,
): Promise<UpdatePassword> {
if (!userId) {
throw new AuthException(
@ -433,7 +431,10 @@ export class AuthService {
);
}
const user = await this.userRepository.findOneBy({ id: userId });
const user = await this.userRepository.findOne({
where: { id: userId },
relations: { userWorkspaces: true },
});
if (!user) {
throw new AuthException(
@ -442,6 +443,15 @@ export class AuthService {
);
}
const [firstUserWorkspace] = user.userWorkspaces;
if (!firstUserWorkspace) {
throw new AuthException(
'User does not have a workspace',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
const isPasswordValid = PASSWORD_REGEX.test(newPassword);
if (!isPasswordValid) {
@ -461,13 +471,13 @@ export class AuthService {
userName: `${user.firstName} ${user.lastName}`,
email: user.email,
link: this.domainManagerService.getBaseUrl().toString(),
locale,
locale: firstUserWorkspace.locale,
});
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
i18n.activate(locale);
i18n.activate(firstUserWorkspace.locale);
this.emailService.send({
from: `${this.twentyConfigService.get(

View File

@ -23,6 +23,7 @@ export class EmailVerificationResolver {
private readonly domainManagerService: DomainManagerService,
) {}
// TODO: this should be an authenticated endpoint
@Mutation(() => ResendEmailVerificationTokenOutput)
@UseGuards(PublicEndpointGuard)
async resendEmailVerificationToken(

View File

@ -132,7 +132,7 @@ export class SSOService {
async findSSOIdentityProviderById(identityProviderId: string) {
return (await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
relations: ['workspace'],
relations: { workspace: true },
})) as (SSOConfiguration & WorkspaceSSOIdentityProvider) | null;
}

View File

@ -2,6 +2,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { PermissionsOnAllObjectRecords } from 'twenty-shared/constants';
import { APP_LOCALES } from 'twenty-shared/translations';
import {
Column,
CreateDateColumn,
@ -46,7 +47,7 @@ export class UserWorkspace {
id: string;
@Field(() => User)
@ManyToOne(() => User, (user) => user.workspaces, {
@ManyToOne(() => User, (user) => user.userWorkspaces, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
@ -71,8 +72,8 @@ export class UserWorkspace {
defaultAvatarUrl: string;
@Field(() => String, { nullable: false })
@Column({ nullable: false, default: 'en' })
locale: string;
@Column({ nullable: false, default: 'en', type: 'varchar' })
locale: keyof typeof APP_LOCALES;
@Field()
@CreateDateColumn({ type: 'timestamptz' })

View File

@ -615,7 +615,7 @@ describe('UserWorkspaceService', () => {
} as unknown as Workspace;
const user = {
email,
workspaces: [
userWorkspaces: [
{
workspaceId: workspace1.id,
workspace: workspace1,
@ -645,12 +645,14 @@ describe('UserWorkspaceService', () => {
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
'workspaces.workspace.approvedAccessDomains',
],
relations: {
userWorkspaces: {
workspace: {
workspaceSSOIdentityProviders: true,
approvedAccessDomains: true,
},
},
},
});
expect(result).toEqual({
@ -694,7 +696,7 @@ describe('UserWorkspaceService', () => {
const user = {
email,
workspaces: [
userWorkspaces: [
{
workspaceId: workspace1.id,
workspace: workspace1,
@ -727,12 +729,14 @@ describe('UserWorkspaceService', () => {
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
'workspaces.workspace.approvedAccessDomains',
],
relations: {
userWorkspaces: {
workspace: {
workspaceSSOIdentityProviders: true,
approvedAccessDomains: true,
},
},
},
});
expect(result).toEqual({
@ -792,7 +796,7 @@ describe('UserWorkspaceService', () => {
} as unknown as Workspace;
const user = {
id: userId,
workspaces: [{ workspace: workspace1 }, { workspace: workspace2 }],
userWorkspaces: [{ workspace: workspace1 }, { workspace: workspace2 }],
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
@ -803,9 +807,9 @@ describe('UserWorkspaceService', () => {
where: {
id: userId,
},
relations: ['workspaces', 'workspaces.workspace'],
relations: { userWorkspaces: { workspace: true } },
order: {
workspaces: {
userWorkspaces: {
workspace: {
createdAt: 'ASC',
},

View File

@ -222,9 +222,9 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
where: {
id: userId,
},
relations: ['workspaces', 'workspaces.workspace'],
relations: { userWorkspaces: { workspace: true } },
order: {
workspaces: {
userWorkspaces: {
workspace: {
createdAt: 'ASC',
},
@ -232,7 +232,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
},
});
const workspace = user?.workspaces?.[0]?.workspace;
const workspace = user?.userWorkspaces?.[0]?.workspace;
workspaceValidator.assertIsDefinedOrThrow(
workspace,
@ -250,16 +250,18 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
'workspaces.workspace.approvedAccessDomains',
],
relations: {
userWorkspaces: {
workspace: {
workspaceSSOIdentityProviders: true,
approvedAccessDomains: true,
},
},
},
});
const alreadyMemberWorkspaces = user
? user.workspaces.map(({ workspace }) => ({ workspace }))
? user.userWorkspaces.map(({ workspace }) => ({ workspace }))
: [];
const alreadyMemberWorkspacesIds = alreadyMemberWorkspaces.map(

View File

@ -97,13 +97,13 @@ export class UserService extends TypeOrmQueryService<User> {
where: {
id: userId,
},
relations: ['workspaces'],
relations: { userWorkspaces: true },
});
userValidator.assertIsDefinedOrThrow(user);
const prepareForUserDeletionInWorkspaces = await Promise.all(
user.workspaces.map(async (userWorkspace) => {
user.userWorkspaces.map(async (userWorkspace) => {
const { workspaceId } = userWorkspace;
const workspaceMemberRepository =
@ -200,11 +200,11 @@ export class UserService extends TypeOrmQueryService<User> {
const user = await this.userRepository.findOne({
where: {
id: userId,
workspaces: {
userWorkspaces: {
workspaceId,
},
},
relations: ['workspaces'],
relations: { userWorkspaces: true },
});
userValidator.assertIsDefinedOrThrow(

View File

@ -112,7 +112,7 @@ export class User {
@Field(() => [UserWorkspace])
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user)
workspaces: Relation<UserWorkspace[]>;
userWorkspaces: Relation<UserWorkspace[]>;
@Field(() => OnboardingStatus, { nullable: true })
onboardingStatus: OnboardingStatus;

View File

@ -121,7 +121,7 @@ export class UserResolver {
where: {
id: userId,
},
relations: ['workspaces'],
relations: { userWorkspaces: true },
});
userValidator.assertIsDefinedOrThrow(
@ -133,7 +133,7 @@ export class UserResolver {
return user;
}
const currentUserWorkspace = user.workspaces.find(
const currentUserWorkspace = user.userWorkspaces.find(
(userWorkspace) => userWorkspace.workspaceId === workspace.id,
);
@ -387,6 +387,13 @@ export class UserResolver {
return workspace;
}
@ResolveField(() => [UserWorkspace], {
nullable: false,
})
async workspaces(@Parent() user: User) {
return user.userWorkspaces;
}
@ResolveField(() => AvailableWorkspaces)
async availableWorkspaces(
@AuthUser() user: User,

View File

@ -9,7 +9,6 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendInviteLinkEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared/translations';
import { IsNull, Repository } from 'typeorm';
import {
@ -61,7 +60,7 @@ export class WorkspaceInvitationService {
value: workspacePersonalInviteToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
relations: { workspace: true },
});
if (!appToken) {
@ -119,7 +118,7 @@ export class WorkspaceInvitationService {
value: invitationToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
relations: { workspace: true },
});
if (!appToken) {
@ -298,7 +297,7 @@ export class WorkspaceInvitationService {
lastName: sender.name.lastName,
},
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: sender.locale as keyof typeof APP_LOCALES,
locale: sender.locale,
};
const emailTemplate = SendInviteLinkEmail(emailData);

View File

@ -161,8 +161,8 @@ export class MiddlewareService {
request.authProvider = data.authProvider;
request.locale =
((data.userWorkspace?.locale ??
request.headers['x-locale']) as keyof typeof APP_LOCALES) ??
data.userWorkspace?.locale ??
(request.headers['x-locale'] as keyof typeof APP_LOCALES) ??
SOURCE_LOCALE;
}