From 163cca55da32bf46b8dda0233b16142a8154cbaf Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 7 Apr 2025 14:28:55 +0700 Subject: [PATCH] UBERF-9734: Set default account timezone (#8469) Signed-off-by: Artem Savchenko --- packages/account-client/src/client.ts | 17 +++++++- packages/account-client/src/utils.ts | 9 ++++ packages/core/src/classes.ts | 5 +++ server/account/src/__tests__/utils.test.ts | 37 +++++++++++++--- server/account/src/operations.ts | 50 ++++++++++++++++++---- server/account/src/types.ts | 8 +++- server/account/src/utils.ts | 30 ++++++++++++- 7 files changed, 139 insertions(+), 17 deletions(-) diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 4697cd8ecd..dcc2c958e0 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -14,6 +14,7 @@ // import { type AccountRole, + type AccountInfo, BackupStatus, Data, type Person, @@ -44,6 +45,7 @@ import type { IntegrationSecret, IntegrationSecretKey } from './types' +import { getClientTimezone } from './utils' /** @public */ export interface AccountClient { @@ -159,6 +161,7 @@ export interface AccountClient { workspaceUuid?: WorkspaceUuid | null key?: string }) => Promise + getAccountInfo: (uuid: PersonUuid) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -213,11 +216,14 @@ class AccountClientImpl implements AccountClient { } private async rpc(request: Request): Promise { + const timezone = getClientTimezone() + const meta: Record = timezone !== undefined ? { 'X-Timezone': timezone } : {} const response = await fetch(this.url, { ...this.request, headers: { ...this.request.headers, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...meta }, method: 'POST', body: JSON.stringify(request) @@ -843,6 +849,15 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } + async getAccountInfo (uuid: PersonUuid): Promise { + const request = { + method: 'getAccountInfo' as const, + params: { accountId: uuid } + } + + return await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/packages/account-client/src/utils.ts b/packages/account-client/src/utils.ts index cbca030bcb..a368564dfc 100644 --- a/packages/account-client/src/utils.ts +++ b/packages/account-client/src/utils.ts @@ -18,3 +18,12 @@ import type { LoginInfo, WorkspaceLoginInfo } from './types' export function isWorkspaceLoginInfo (loginInfo: LoginInfo | WorkspaceLoginInfo): loginInfo is WorkspaceLoginInfo { return (loginInfo as WorkspaceLoginInfo).workspace != null } + +export function getClientTimezone (): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch (err: any) { + console.error('Failed to get client timezone', err) + return undefined + } +} diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 16811d048f..34c3eb38f3 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -859,4 +859,9 @@ export interface SocialId { verifiedOn?: number } +export interface AccountInfo { + timezone?: string + locale?: string +} + export type SocialKey = Pick diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 0ec2be6be8..a5f46ef81b 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -544,10 +544,17 @@ describe('account utils', () => { const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') expect(result).toEqual({ id: 'req1', result: mockResult }) - expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, 'token', { - param1: 'value1', - param2: 'value2' - }) + expect(mockMethod).toHaveBeenCalledWith( + mockCtx, + mockDb, + mockBranding, + 'token', + { + param1: 'value1', + param2: 'value2' + }, + {} + ) }) test('should handle token parameter', async () => { @@ -559,7 +566,7 @@ describe('account utils', () => { const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') expect(result).toEqual({ id: 'req1', result: mockResult }) - expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, 'token', { param1: 'value1' }) + expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, 'token', { param1: 'value1' }, {}) }) test('should handle PlatformError', async () => { @@ -609,6 +616,26 @@ describe('account utils', () => { await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') }) + + test('should handle timezone parameter', async () => { + const mockResult = { data: 'test' } + const mockMethod = jest.fn().mockResolvedValue(mockResult) + const wrappedMethod = wrap(mockMethod) + const mockTimezone = 'America/New_York' + const request = { id: 'req1', params: { param1: 'value1' }, headers: { 'X-Timezone': mockTimezone } } + + const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') + + expect(result).toEqual({ id: 'req1', result: mockResult }) + expect(mockMethod).toHaveBeenCalledWith( + mockCtx, + mockDb, + mockBranding, + 'token', + { param1: 'value1' }, + { timezone: mockTimezone } + ) + }) }) describe('with mocked fetch', () => { diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 334b4fc8ca..c90b6d3aac 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -15,6 +15,7 @@ import { Analytics } from '@hcengineering/analytics' import { AccountRole, + AccountInfo, buildSocialIdString, concatLink, isActiveMode, @@ -39,6 +40,7 @@ import type { AccountDB, AccountMethodHandler, LoginInfo, + Meta, Mailbox, MailboxOptions, OtpInfo, @@ -86,7 +88,8 @@ import { generatePassword, addSocialId, releaseSocialId, - updateWorkspaceRole + updateWorkspaceRole, + setTimezoneIfNotDefined } from './utils' import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' @@ -112,7 +115,8 @@ export async function login ( params: { email: string password: string - } + }, + meta?: Meta ): Promise { const { email, password } = params const normalizedEmail = cleanEmail(email) @@ -143,6 +147,7 @@ export async function login ( const extraToken: Record = isAdminEmail(email) ? { admin: 'true' } : {} ctx.info('Login succeeded', { email, normalizedEmail, isConfirmed, emailSocialId, ...extraToken }) + void setTimezoneIfNotDefined(ctx, db, emailSocialId.personUuid, existingAccount, meta) return { account: existingAccount.uuid, @@ -165,7 +170,8 @@ export async function loginOtp ( db: AccountDB, branding: Branding | null, token: string, - params: { email: string } + params: { email: string }, + meta?: Meta ): Promise { const { email } = params @@ -183,6 +189,8 @@ export async function loginOtp ( throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) } + void setTimezoneIfNotDefined(ctx, db, emailSocialId.personUuid, account, meta) + return await sendOtp(ctx, db, branding, emailSocialId) } @@ -200,7 +208,8 @@ export async function signUp ( password: string firstName: string lastName: string - } + }, + meta?: Meta ): Promise { const { email, password, firstName, lastName } = params const { account, socialId } = await signUpByEmail(ctx, db, branding, email, password, firstName, lastName) @@ -220,6 +229,7 @@ export async function signUp ( await confirmEmail(ctx, db, account, email) } + void setTimezoneIfNotDefined(ctx, db, account, null, meta) return { account, name: getPersonName(person), @@ -237,7 +247,8 @@ export async function signUpOtp ( email: string firstName: string lastName: string - } + }, + meta?: Meta ): Promise { const { email, firstName, lastName } = params // Note: can support OTP based on any other social logins later @@ -263,6 +274,7 @@ export async function signUpOtp ( const emailSocialIdId = await db.socialId.insertOne(newSocialId) emailSocialId = { ...newSocialId, _id: emailSocialIdId, key: buildSocialIdString(newSocialId) } } + void setTimezoneIfNotDefined(ctx, db, personUuid, null, meta) return await sendOtp(ctx, db, branding, emailSocialId) } @@ -633,7 +645,8 @@ export async function join ( email: string password: string inviteId: string - } + }, + meta?: Meta ): Promise { const { email, password, inviteId } = params const normalizedEmail = cleanEmail(email) @@ -651,7 +664,7 @@ export async function join ( ctx.info('Joining a workspace using invite', { email, normalizedEmail, ...invite }) - const { token, account } = await login(ctx, db, branding, _token, { email: normalizedEmail, password }) + const { token, account } = await login(ctx, db, branding, _token, { email: normalizedEmail, password }, meta) if (token == null) { return { @@ -829,7 +842,8 @@ export async function signUpJoin ( first: string last: string inviteId: string - } + }, + meta?: Meta ): Promise { const { email, password, first, last, inviteId } = params const normalizedEmail = cleanEmail(email) @@ -848,6 +862,7 @@ export async function signUpJoin ( } const { account } = await signUpByEmail(ctx, db, branding, email, password, first, last, true) + void setTimezoneIfNotDefined(ctx, db, account, null, meta) return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite) } @@ -1493,6 +1508,22 @@ export async function getWorkspaceMembers ( return await db.getWorkspaceMembers(workspace) } +export async function getAccountInfo ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountId: PersonUuid } +): Promise { + decodeTokenVerbose(ctx, token) + const { accountId } = params + const account = await getAccount(db, accountId) + if (account === undefined || account === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + return { timezone: account?.timezone, locale: account?.locale } +} + export async function ensurePerson ( ctx: MeasureContext, db: AccountDB, @@ -1669,6 +1700,8 @@ export type AccountMethods = | 'createMailbox' | 'getMailboxes' | 'deleteMailbox' + | 'addSocialIdToPerson' + | 'getAccountInfo' /** * @public @@ -1719,6 +1752,7 @@ export function getMethods (hasSignUp: boolean = true): Partial, + meta?: Record ) => Promise export type WorkspaceEvent = @@ -302,3 +304,7 @@ export interface MailboxOptions { maxNameLength: number maxMailboxCount: number } + +export interface Meta { + timezone?: string +} diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index bdb7d107d0..8d299437c6 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -54,7 +54,8 @@ import { LoginInfo, WorkspaceLoginInfo, WorkspaceStatus, - AccountEventType + AccountEventType, + Meta } from './types' import { Analytics } from '@hcengineering/analytics' import { TokenError, decodeTokenVerbose, generateToken } from '@hcengineering/server-token' @@ -118,7 +119,11 @@ export function wrap ( request: any, token?: string ): Promise { - return await accountMethod(ctx, db, branding, token, { ...request.params }) + const meta = + request.headers !== undefined && request.headers['X-Timezone'] !== undefined + ? { timezone: request.headers['X-Timezone'] } + : {} + return await accountMethod(ctx, db, branding, token, { ...request.params }, meta) .then((result) => ({ id: request.id, result })) .catch((err) => { const status = @@ -1384,3 +1389,24 @@ export async function getWorkspaceRole ( export function generatePassword (len: number = 24): string { return randomBytes(len).toString('base64').slice(0, len) } + +export async function setTimezoneIfNotDefined ( + ctx: MeasureContext, + db: AccountDB, + accountId: PersonUuid, + account: Account | null | undefined, + meta?: Meta +): Promise { + try { + if (meta?.timezone === undefined) return + const existingAccount = account ?? (await db.account.findOne({ uuid: accountId })) + if (existingAccount === undefined || existingAccount === null) { + ctx.warn('Failed to find account') + return + } + if (existingAccount.timezone != null) return + await db.account.updateOne({ uuid: accountId }, { timezone: meta.timezone }) + } catch (err: any) { + ctx.error('Failed to set account timezone', err) + } +}