UBERF-9734: Set default account timezone (#8469)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko 2025-04-07 14:28:55 +07:00 committed by GitHub
parent e2a2011ad8
commit 163cca55da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 139 additions and 17 deletions

View File

@ -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<IntegrationSecret[]>
getAccountInfo: (uuid: PersonUuid) => Promise<AccountInfo>
setCookie: () => Promise<void>
deleteCookie: () => Promise<void>
@ -213,11 +216,14 @@ class AccountClientImpl implements AccountClient {
}
private async rpc<T>(request: Request): Promise<T> {
const timezone = getClientTimezone()
const meta: Record<string, string> = 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<AccountInfo> {
const request = {
method: 'getAccountInfo' as const,
params: { accountId: uuid }
}
return await this.rpc(request)
}
async setCookie (): Promise<void> {
const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' })

View File

@ -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
}
}

View File

@ -859,4 +859,9 @@ export interface SocialId {
verifiedOn?: number
}
export interface AccountInfo {
timezone?: string
locale?: string
}
export type SocialKey = Pick<SocialId, 'type' | 'value'>

View File

@ -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', () => {

View File

@ -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<LoginInfo> {
const { email, password } = params
const normalizedEmail = cleanEmail(email)
@ -143,6 +147,7 @@ export async function login (
const extraToken: Record<string, string> = 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<OtpInfo> {
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<LoginInfo> {
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<OtpInfo> {
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<WorkspaceLoginInfo | LoginInfo> {
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<WorkspaceLoginInfo> {
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<AccountInfo> {
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<Record<AccountMe
findSocialIdBySocialKey: wrap(findSocialIdBySocialKey),
getWorkspaceMembers: wrap(getWorkspaceMembers),
getMailboxOptions: wrap(getMailboxOptions),
getAccountInfo: wrap(getAccountInfo),
/* SERVICE METHODS */
...getServiceMethods()

View File

@ -243,7 +243,9 @@ export type AccountMethodHandler = (
db: AccountDB,
branding: Branding | null,
request: any,
token: string | undefined
token: string | undefined,
params?: Record<string, any>,
meta?: Record<string, any>
) => Promise<any>
export type WorkspaceEvent =
@ -302,3 +304,7 @@ export interface MailboxOptions {
maxNameLength: number
maxMailboxCount: number
}
export interface Meta {
timezone?: string
}

View File

@ -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<any> {
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<void> {
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)
}
}