mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-08 02:37:44 +02:00
uberf-9754: fix account timestamp (#8520)
This commit is contained in:
@@ -184,6 +184,7 @@ interface Request {
|
||||
|
||||
class AccountClientImpl implements AccountClient {
|
||||
private readonly request: RequestInit
|
||||
private readonly rpc: typeof this._rpc
|
||||
|
||||
constructor (
|
||||
private readonly url: string,
|
||||
@@ -206,17 +207,18 @@ class AccountClientImpl implements AccountClient {
|
||||
},
|
||||
...(isBrowser ? { credentials: 'include' } : {})
|
||||
}
|
||||
this.rpc = withRetryUntilTimeout(this._rpc.bind(this))
|
||||
}
|
||||
|
||||
async getProviders (): Promise<string[]> {
|
||||
return await retry(5, async () => {
|
||||
return await withRetryUntilMaxAttempts(async () => {
|
||||
const response = await fetch(concatLink(this.url, '/providers'))
|
||||
|
||||
return await response.json()
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
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, {
|
||||
@@ -902,18 +904,40 @@ class AccountClientImpl implements AccountClient {
|
||||
}
|
||||
}
|
||||
|
||||
async function retry<T> (retries: number, op: () => Promise<T>, delay: number = 100): Promise<T> {
|
||||
let error: any
|
||||
while (retries > 0) {
|
||||
retries--
|
||||
try {
|
||||
return await op()
|
||||
} catch (err: any) {
|
||||
error = err
|
||||
if (retries !== 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
function withRetry<T, F extends (...args: any[]) => Promise<T>> (
|
||||
f: F,
|
||||
shouldFail: (err: any, attempt: number) => boolean,
|
||||
intervalMs: number = 1000
|
||||
): F {
|
||||
return async function (...params: any[]): Promise<T> {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return await f(...params)
|
||||
} catch (err: any) {
|
||||
if (shouldFail(err, attempt)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
attempt++
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} as F
|
||||
}
|
||||
|
||||
const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND']
|
||||
|
||||
function withRetryUntilTimeout<T, F extends (...args: any[]) => Promise<T>> (f: F, timeoutMs: number = 5000): F {
|
||||
const timeout = Date.now() + timeoutMs
|
||||
const shouldFail = (err: any): boolean => !connectionErrorCodes.includes(err?.cause?.code) || timeout < Date.now()
|
||||
|
||||
return withRetry(f, shouldFail)
|
||||
}
|
||||
|
||||
function withRetryUntilMaxAttempts<T, F extends (...args: any[]) => Promise<T>> (f: F, maxAttempts: number = 5): F {
|
||||
const shouldFail = (err: any, attempt: number): boolean =>
|
||||
!connectionErrorCodes.includes(err?.cause?.code) || attempt === maxAttempts
|
||||
|
||||
return withRetry(f, shouldFail)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface TestWorkspace {
|
||||
uuid: WorkspaceUuid
|
||||
mode: WorkspaceMode
|
||||
name: string
|
||||
createdOn: number
|
||||
processingAttempts?: number
|
||||
lastProcessingTime?: number
|
||||
}
|
||||
@@ -42,7 +43,7 @@ describe('PostgresDbCollection', () => {
|
||||
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
|
||||
}
|
||||
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', ns)
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, { idKey: 'uuid', ns })
|
||||
})
|
||||
|
||||
describe('getTableName', () => {
|
||||
@@ -51,17 +52,18 @@ describe('PostgresDbCollection', () => {
|
||||
})
|
||||
|
||||
it('should return table name without namespace when ns is empty', () => {
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', '')
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
|
||||
idKey: 'uuid',
|
||||
ns: ''
|
||||
})
|
||||
expect(collection.getTableName()).toBe('workspace')
|
||||
})
|
||||
|
||||
it('should return table name with custom namespace when ns is provided', () => {
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>(
|
||||
'workspace',
|
||||
mockClient as Sql,
|
||||
'uuid',
|
||||
'custom_account'
|
||||
)
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
|
||||
idKey: 'uuid',
|
||||
ns: 'custom_account'
|
||||
})
|
||||
expect(collection.getTableName()).toBe('custom_account.workspace')
|
||||
})
|
||||
})
|
||||
@@ -199,6 +201,92 @@ describe('PostgresDbCollection', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('timestamp field handling', () => {
|
||||
beforeEach(() => {
|
||||
// Create collection with timestamp fields specified
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
|
||||
idKey: 'uuid',
|
||||
ns,
|
||||
timestampFields: ['lastProcessingTime', 'createdOn']
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert string timestamps to numbers', async () => {
|
||||
// Mock database returning string timestamps
|
||||
mockClient.unsafe.mockResolvedValue([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
last_processing_time: '1234567890000',
|
||||
created_on: '1234567891000'
|
||||
}
|
||||
])
|
||||
|
||||
const result = await collection.find({})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
lastProcessingTime: 1234567890000,
|
||||
createdOn: 1234567891000
|
||||
}
|
||||
])
|
||||
expect(typeof result[0].lastProcessingTime).toBe('number')
|
||||
expect(typeof result[0].createdOn).toBe('number')
|
||||
})
|
||||
|
||||
it('should handle null timestamp values', async () => {
|
||||
mockClient.unsafe.mockResolvedValue([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
last_processing_time: null,
|
||||
created_on: null
|
||||
}
|
||||
])
|
||||
|
||||
const result = await collection.find({})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
lastProcessingTime: null,
|
||||
createdOn: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle invalid timestamp strings', async () => {
|
||||
mockClient.unsafe.mockResolvedValue([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
last_processing_time: 'invalid',
|
||||
created_on: ''
|
||||
}
|
||||
])
|
||||
|
||||
const result = await collection.find({})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
uuid: 'ws1',
|
||||
mode: 'active',
|
||||
name: 'Test',
|
||||
lastProcessingTime: null,
|
||||
createdOn: null
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AccountPostgresDbCollection', () => {
|
||||
|
||||
@@ -92,16 +92,37 @@ function formatVar (idx: number, type?: string): string {
|
||||
return type != null ? `$${idx}::${type}` : `$${idx}`
|
||||
}
|
||||
|
||||
export interface PostgresDbCollectionOptions<T extends Record<string, any>, K extends keyof T | undefined = undefined> {
|
||||
idKey?: K
|
||||
ns?: string
|
||||
fieldTypes?: Record<string, string>
|
||||
timestampFields?: Array<keyof T>
|
||||
}
|
||||
|
||||
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
|
||||
implements DbCollection<T> {
|
||||
constructor (
|
||||
readonly name: string,
|
||||
readonly client: Sql,
|
||||
readonly idKey?: K,
|
||||
readonly ns?: string,
|
||||
readonly fieldTypes: Record<string, string> = {}
|
||||
readonly options: PostgresDbCollectionOptions<T, K> = {}
|
||||
) {}
|
||||
|
||||
get ns (): string {
|
||||
return this.options.ns ?? ''
|
||||
}
|
||||
|
||||
get idKey (): K | undefined {
|
||||
return this.options.idKey
|
||||
}
|
||||
|
||||
get fieldTypes (): Record<string, string> {
|
||||
return this.options.fieldTypes ?? {}
|
||||
}
|
||||
|
||||
get timestampFields (): Array<keyof T> {
|
||||
return this.options.timestampFields ?? []
|
||||
}
|
||||
|
||||
getTableName (): string {
|
||||
if (this.ns === '') {
|
||||
return this.name
|
||||
@@ -206,7 +227,13 @@ implements DbCollection<T> {
|
||||
}
|
||||
|
||||
protected convertToObj (row: unknown): T {
|
||||
return convertKeysToCamelCase(row) as T
|
||||
const res = convertKeysToCamelCase(row)
|
||||
for (const field of this.timestampFields) {
|
||||
const val = Number.parseInt(res[field])
|
||||
res[field] = Number.isNaN(val) ? null : val
|
||||
}
|
||||
|
||||
return res as T
|
||||
}
|
||||
|
||||
async find (query: Query<T>, sort?: Sort<T>, limit?: number, client?: Sql): Promise<T[]> {
|
||||
@@ -319,11 +346,8 @@ export class AccountPostgresDbCollection
|
||||
implements DbCollection<Account> {
|
||||
private readonly passwordKeys = ['hash', 'salt']
|
||||
|
||||
constructor (
|
||||
readonly client: Sql,
|
||||
readonly ns?: string
|
||||
) {
|
||||
super('account', client, 'uuid', ns)
|
||||
constructor (client: Sql, ns?: string) {
|
||||
super('account', client, { idKey: 'uuid', ns })
|
||||
}
|
||||
|
||||
getPasswordsTableName (): string {
|
||||
@@ -413,18 +437,36 @@ export class PostgresAccountDB implements AccountDB {
|
||||
readonly client: Sql,
|
||||
readonly ns: string = 'global_account'
|
||||
) {
|
||||
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
|
||||
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, { ns, idKey: 'uuid' })
|
||||
this.account = new AccountPostgresDbCollection(client, ns)
|
||||
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
|
||||
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
|
||||
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
|
||||
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
|
||||
this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
|
||||
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
|
||||
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
|
||||
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, undefined, ns)
|
||||
this.integration = new PostgresDbCollection<Integration>('integrations', client, undefined, ns)
|
||||
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, undefined, ns)
|
||||
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, {
|
||||
ns,
|
||||
idKey: '_id',
|
||||
timestampFields: ['createdOn', 'verifiedOn']
|
||||
})
|
||||
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, {
|
||||
ns,
|
||||
timestampFields: ['lastProcessingTime', 'lastVisit']
|
||||
})
|
||||
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, {
|
||||
ns,
|
||||
idKey: 'uuid',
|
||||
timestampFields: ['createdOn']
|
||||
})
|
||||
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, {
|
||||
ns,
|
||||
timestampFields: ['time']
|
||||
})
|
||||
this.otp = new PostgresDbCollection<OTP>('otp', client, { ns, timestampFields: ['expiresOn', 'createdOn'] })
|
||||
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, {
|
||||
ns,
|
||||
idKey: 'id',
|
||||
timestampFields: ['expiresOn']
|
||||
})
|
||||
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, { ns })
|
||||
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, { ns })
|
||||
this.integration = new PostgresDbCollection<Integration>('integrations', client, { ns })
|
||||
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, { ns })
|
||||
}
|
||||
|
||||
getWsMembersTableName (): string {
|
||||
|
||||
@@ -1449,6 +1449,10 @@ export async function findSocialIdBySocialKey (
|
||||
const { socialKey, requireAccount } = params
|
||||
decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (socialKey == null || socialKey === '') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
|
||||
}
|
||||
|
||||
const socialIdObj = await db.socialId.findOne({ key: socialKey })
|
||||
|
||||
if (socialIdObj == null) {
|
||||
@@ -1684,7 +1688,7 @@ export type AccountMethods =
|
||||
| 'signUpJoin'
|
||||
| 'confirm'
|
||||
| 'changePassword'
|
||||
| 'requestPassword'
|
||||
| 'requestPasswordReset'
|
||||
| 'restorePassword'
|
||||
| 'leaveWorkspace'
|
||||
| 'changeUsername'
|
||||
@@ -1736,7 +1740,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
||||
signUpJoin: wrap(signUpJoin),
|
||||
confirm: wrap(confirm),
|
||||
changePassword: wrap(changePassword),
|
||||
requestPassword: wrap(requestPasswordReset),
|
||||
requestPasswordReset: wrap(requestPasswordReset),
|
||||
restorePassword: wrap(restorePassword),
|
||||
leaveWorkspace: wrap(leaveWorkspace),
|
||||
changeUsername: wrap(changeUsername),
|
||||
|
||||
Reference in New Issue
Block a user