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 { import {
type AccountRole, type AccountRole,
type AccountInfo,
BackupStatus, BackupStatus,
Data, Data,
type Person, type Person,
@ -44,6 +45,7 @@ import type {
IntegrationSecret, IntegrationSecret,
IntegrationSecretKey IntegrationSecretKey
} from './types' } from './types'
import { getClientTimezone } from './utils'
/** @public */ /** @public */
export interface AccountClient { export interface AccountClient {
@ -159,6 +161,7 @@ export interface AccountClient {
workspaceUuid?: WorkspaceUuid | null workspaceUuid?: WorkspaceUuid | null
key?: string key?: string
}) => Promise<IntegrationSecret[]> }) => Promise<IntegrationSecret[]>
getAccountInfo: (uuid: PersonUuid) => Promise<AccountInfo>
setCookie: () => Promise<void> setCookie: () => Promise<void>
deleteCookie: () => Promise<void> deleteCookie: () => Promise<void>
@ -213,11 +216,14 @@ class AccountClientImpl implements AccountClient {
} }
private async rpc<T>(request: Request): Promise<T> { 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, { const response = await fetch(this.url, {
...this.request, ...this.request,
headers: { headers: {
...this.request.headers, ...this.request.headers,
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...meta
}, },
method: 'POST', method: 'POST',
body: JSON.stringify(request) body: JSON.stringify(request)
@ -843,6 +849,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request) 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> { async setCookie (): Promise<void> {
const url = concatLink(this.url, '/cookie') const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' }) 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 { export function isWorkspaceLoginInfo (loginInfo: LoginInfo | WorkspaceLoginInfo): loginInfo is WorkspaceLoginInfo {
return (loginInfo as WorkspaceLoginInfo).workspace != null 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 verifiedOn?: number
} }
export interface AccountInfo {
timezone?: string
locale?: string
}
export type SocialKey = Pick<SocialId, 'type' | 'value'> 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') const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token')
expect(result).toEqual({ id: 'req1', result: mockResult }) expect(result).toEqual({ id: 'req1', result: mockResult })
expect(mockMethod).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, 'token', { expect(mockMethod).toHaveBeenCalledWith(
param1: 'value1', mockCtx,
param2: 'value2' mockDb,
}) mockBranding,
'token',
{
param1: 'value1',
param2: 'value2'
},
{}
)
}) })
test('should handle token parameter', async () => { test('should handle token parameter', async () => {
@ -559,7 +566,7 @@ describe('account utils', () => {
const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') const result = await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token')
expect(result).toEqual({ id: 'req1', result: mockResult }) 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 () => { test('should handle PlatformError', async () => {
@ -609,6 +616,26 @@ describe('account utils', () => {
await wrappedMethod(mockCtx, mockDb, mockBranding, request, 'token') 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', () => { describe('with mocked fetch', () => {

View File

@ -15,6 +15,7 @@
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { import {
AccountRole, AccountRole,
AccountInfo,
buildSocialIdString, buildSocialIdString,
concatLink, concatLink,
isActiveMode, isActiveMode,
@ -39,6 +40,7 @@ import type {
AccountDB, AccountDB,
AccountMethodHandler, AccountMethodHandler,
LoginInfo, LoginInfo,
Meta,
Mailbox, Mailbox,
MailboxOptions, MailboxOptions,
OtpInfo, OtpInfo,
@ -86,7 +88,8 @@ import {
generatePassword, generatePassword,
addSocialId, addSocialId,
releaseSocialId, releaseSocialId,
updateWorkspaceRole updateWorkspaceRole,
setTimezoneIfNotDefined
} from './utils' } from './utils'
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
@ -112,7 +115,8 @@ export async function login (
params: { params: {
email: string email: string
password: string password: string
} },
meta?: Meta
): Promise<LoginInfo> { ): Promise<LoginInfo> {
const { email, password } = params const { email, password } = params
const normalizedEmail = cleanEmail(email) const normalizedEmail = cleanEmail(email)
@ -143,6 +147,7 @@ export async function login (
const extraToken: Record<string, string> = isAdminEmail(email) ? { admin: 'true' } : {} const extraToken: Record<string, string> = isAdminEmail(email) ? { admin: 'true' } : {}
ctx.info('Login succeeded', { email, normalizedEmail, isConfirmed, emailSocialId, ...extraToken }) ctx.info('Login succeeded', { email, normalizedEmail, isConfirmed, emailSocialId, ...extraToken })
void setTimezoneIfNotDefined(ctx, db, emailSocialId.personUuid, existingAccount, meta)
return { return {
account: existingAccount.uuid, account: existingAccount.uuid,
@ -165,7 +170,8 @@ export async function loginOtp (
db: AccountDB, db: AccountDB,
branding: Branding | null, branding: Branding | null,
token: string, token: string,
params: { email: string } params: { email: string },
meta?: Meta
): Promise<OtpInfo> { ): Promise<OtpInfo> {
const { email } = params const { email } = params
@ -183,6 +189,8 @@ export async function loginOtp (
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) 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) return await sendOtp(ctx, db, branding, emailSocialId)
} }
@ -200,7 +208,8 @@ export async function signUp (
password: string password: string
firstName: string firstName: string
lastName: string lastName: string
} },
meta?: Meta
): Promise<LoginInfo> { ): Promise<LoginInfo> {
const { email, password, firstName, lastName } = params const { email, password, firstName, lastName } = params
const { account, socialId } = await signUpByEmail(ctx, db, branding, email, password, firstName, lastName) 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) await confirmEmail(ctx, db, account, email)
} }
void setTimezoneIfNotDefined(ctx, db, account, null, meta)
return { return {
account, account,
name: getPersonName(person), name: getPersonName(person),
@ -237,7 +247,8 @@ export async function signUpOtp (
email: string email: string
firstName: string firstName: string
lastName: string lastName: string
} },
meta?: Meta
): Promise<OtpInfo> { ): Promise<OtpInfo> {
const { email, firstName, lastName } = params const { email, firstName, lastName } = params
// Note: can support OTP based on any other social logins later // 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) const emailSocialIdId = await db.socialId.insertOne(newSocialId)
emailSocialId = { ...newSocialId, _id: emailSocialIdId, key: buildSocialIdString(newSocialId) } emailSocialId = { ...newSocialId, _id: emailSocialIdId, key: buildSocialIdString(newSocialId) }
} }
void setTimezoneIfNotDefined(ctx, db, personUuid, null, meta)
return await sendOtp(ctx, db, branding, emailSocialId) return await sendOtp(ctx, db, branding, emailSocialId)
} }
@ -633,7 +645,8 @@ export async function join (
email: string email: string
password: string password: string
inviteId: string inviteId: string
} },
meta?: Meta
): Promise<WorkspaceLoginInfo | LoginInfo> { ): Promise<WorkspaceLoginInfo | LoginInfo> {
const { email, password, inviteId } = params const { email, password, inviteId } = params
const normalizedEmail = cleanEmail(email) const normalizedEmail = cleanEmail(email)
@ -651,7 +664,7 @@ export async function join (
ctx.info('Joining a workspace using invite', { email, normalizedEmail, ...invite }) 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) { if (token == null) {
return { return {
@ -829,7 +842,8 @@ export async function signUpJoin (
first: string first: string
last: string last: string
inviteId: string inviteId: string
} },
meta?: Meta
): Promise<WorkspaceLoginInfo> { ): Promise<WorkspaceLoginInfo> {
const { email, password, first, last, inviteId } = params const { email, password, first, last, inviteId } = params
const normalizedEmail = cleanEmail(email) const normalizedEmail = cleanEmail(email)
@ -848,6 +862,7 @@ export async function signUpJoin (
} }
const { account } = await signUpByEmail(ctx, db, branding, email, password, first, last, true) 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) 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) 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 ( export async function ensurePerson (
ctx: MeasureContext, ctx: MeasureContext,
db: AccountDB, db: AccountDB,
@ -1669,6 +1700,8 @@ export type AccountMethods =
| 'createMailbox' | 'createMailbox'
| 'getMailboxes' | 'getMailboxes'
| 'deleteMailbox' | 'deleteMailbox'
| 'addSocialIdToPerson'
| 'getAccountInfo'
/** /**
* @public * @public
@ -1719,6 +1752,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
findSocialIdBySocialKey: wrap(findSocialIdBySocialKey), findSocialIdBySocialKey: wrap(findSocialIdBySocialKey),
getWorkspaceMembers: wrap(getWorkspaceMembers), getWorkspaceMembers: wrap(getWorkspaceMembers),
getMailboxOptions: wrap(getMailboxOptions), getMailboxOptions: wrap(getMailboxOptions),
getAccountInfo: wrap(getAccountInfo),
/* SERVICE METHODS */ /* SERVICE METHODS */
...getServiceMethods() ...getServiceMethods()

View File

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

View File

@ -54,7 +54,8 @@ import {
LoginInfo, LoginInfo,
WorkspaceLoginInfo, WorkspaceLoginInfo,
WorkspaceStatus, WorkspaceStatus,
AccountEventType AccountEventType,
Meta
} from './types' } from './types'
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { TokenError, decodeTokenVerbose, generateToken } from '@hcengineering/server-token' import { TokenError, decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
@ -118,7 +119,11 @@ export function wrap (
request: any, request: any,
token?: string token?: string
): Promise<any> { ): 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 })) .then((result) => ({ id: request.id, result }))
.catch((err) => { .catch((err) => {
const status = const status =
@ -1384,3 +1389,24 @@ export async function getWorkspaceRole (
export function generatePassword (len: number = 24): string { export function generatePassword (len: number = 24): string {
return randomBytes(len).toString('base64').slice(0, len) 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)
}
}