fix: harden auth and cache concurrency
This commit is contained in:
Generated
+20
-7
@@ -25,6 +25,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"lru-cache": "^11.2.4",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -60,6 +61,9 @@
|
|||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/core": {
|
"node_modules/@angular-devkit/core": {
|
||||||
@@ -306,6 +310,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -8707,13 +8721,12 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "11.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||||
"dev": true,
|
"license": "BlueOak-1.0.0",
|
||||||
"license": "ISC",
|
"engines": {
|
||||||
"dependencies": {
|
"node": "20 || >=22"
|
||||||
"yallist": "^3.0.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"lru-cache": "^11.2.4",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
jest.mock('bcrypt', () => ({
|
||||||
|
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||||
|
compare: jest.fn(() => Promise.resolve(true)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class PrismaService {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 'user',
|
||||||
|
email: 'user@example.com',
|
||||||
|
passwordHash: 'password-hash',
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function hash(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoCallBarrier<T>(value: T): () => Promise<T> {
|
||||||
|
let calls = 0;
|
||||||
|
let release: () => void;
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 2) release();
|
||||||
|
await ready;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
const jwt = new JwtService({ secret: 'test-secret-that-is-long-enough' });
|
||||||
|
const prisma = {
|
||||||
|
user: { findUnique: jest.fn(), create: jest.fn() },
|
||||||
|
session: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new AuthService(prisma as never, jwt);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the database loser of a concurrent registration to EMAIL_ALREADY_REGISTERED', async () => {
|
||||||
|
prisma.user.findUnique.mockImplementation(twoCallBarrier(null));
|
||||||
|
prisma.user.create
|
||||||
|
.mockResolvedValueOnce(user)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('duplicate email'), { code: 'P2002' }),
|
||||||
|
);
|
||||||
|
prisma.session.create.mockResolvedValue({
|
||||||
|
id: 'session',
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: '',
|
||||||
|
expiresAt: new Date(),
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
prisma.session.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.register(user.email, 'secure password'),
|
||||||
|
service.register(user.email, 'secure password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rejected = results.find(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
results.filter((result) => result.status === 'fulfilled'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(rejected?.reason).toEqual(expect.any(ConflictException));
|
||||||
|
expect((rejected?.reason as ConflictException).getResponse()).toEqual({
|
||||||
|
code: 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows one concurrent refresh winner without revoking its session', async () => {
|
||||||
|
const oldToken = await jwt.signAsync(
|
||||||
|
{ sub: user.id, sid: 'session', type: 'refresh', jti: 'old-token' },
|
||||||
|
{ expiresIn: '7d', algorithm: 'HS256' },
|
||||||
|
);
|
||||||
|
let currentHash = hash(oldToken);
|
||||||
|
const session = {
|
||||||
|
id: 'session',
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: currentHash,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
prisma.session.findUnique.mockImplementation(twoCallBarrier(session));
|
||||||
|
prisma.session.updateMany.mockImplementation(
|
||||||
|
({
|
||||||
|
where,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
where: { id?: string; refreshTokenHash?: string };
|
||||||
|
data: { refreshTokenHash: string };
|
||||||
|
}) => {
|
||||||
|
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
||||||
|
currentHash = data.refreshTokenHash;
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.refresh(oldToken),
|
||||||
|
service.refresh(oldToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rejected = results.find(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
results.filter((result) => result.status === 'fulfilled'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(rejected?.reason).toEqual(expect.any(UnauthorizedException));
|
||||||
|
expect(prisma.session.updateMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(
|
||||||
|
prisma.session.updateMany.mock.calls.map(
|
||||||
|
([input]) =>
|
||||||
|
(input as { where: { id?: string; refreshTokenHash?: string } })
|
||||||
|
.where,
|
||||||
|
),
|
||||||
|
).toContainEqual({ id: session.id, refreshTokenHash: hash(oldToken) });
|
||||||
|
expect(prisma.session.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-21
@@ -33,13 +33,20 @@ export class AuthService {
|
|||||||
if (existing)
|
if (existing)
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
|
|
||||||
const user = await this.prisma.user.create({
|
let user: User;
|
||||||
data: {
|
try {
|
||||||
id: randomUUID(),
|
user = await this.prisma.user.create({
|
||||||
email: normalizedEmail,
|
data: {
|
||||||
passwordHash: await bcrypt.hash(password, 12),
|
id: randomUUID(),
|
||||||
},
|
email: normalizedEmail,
|
||||||
});
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error))
|
||||||
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,14 +93,9 @@ export class AuthService {
|
|||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
if (session.refreshTokenHash !== this.hash(token)) {
|
if (session.refreshTokenHash !== this.hash(token))
|
||||||
await this.prisma.session.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
return this.rotateSession(session, session.refreshTokenHash);
|
||||||
return this.rotateSession(session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(token?: string): Promise<void> {
|
async logout(token?: string): Promise<void> {
|
||||||
@@ -147,6 +149,7 @@ export class AuthService {
|
|||||||
|
|
||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
@@ -157,13 +160,20 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
||||||
);
|
);
|
||||||
await this.prisma.session.update({
|
const data = {
|
||||||
where: { id: session.id },
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
data: {
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
refreshTokenHash: this.hash(refreshToken),
|
};
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
if (expectedRefreshTokenHash) {
|
||||||
},
|
const updated = await this.prisma.session.updateMany({
|
||||||
});
|
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (updated.count !== 1)
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
} else {
|
||||||
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
user: this.toUser(session.user),
|
user: this.toUser(session.user),
|
||||||
accessToken: await this.jwt.signAsync(
|
accessToken: await this.jwt.signAsync(
|
||||||
@@ -182,3 +192,12 @@ export class AuthService {
|
|||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'P2002'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ describe('BrokerageOperationsService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
tbank.getOpenAccounts.mockReset();
|
||||||
|
tbank.getOperationsByCursor.mockReset();
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
encryptedToken: crypto.encrypt('token'),
|
encryptedToken: crypto.encrypt('token'),
|
||||||
});
|
});
|
||||||
@@ -46,6 +48,11 @@ describe('BrokerageOperationsService', () => {
|
|||||||
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
||||||
await service.get('user', 'account', {});
|
await service.get('user', 'account', {});
|
||||||
|
|
||||||
@@ -134,4 +141,41 @@ describe('BrokerageOperationsService', () => {
|
|||||||
expect.any(ServiceUnavailableException),
|
expect.any(ServiceUnavailableException),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
|
||||||
|
|
||||||
|
await service.get('user', 'account', {
|
||||||
|
categories: ['tax', 'income', 'income'],
|
||||||
|
});
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
await service.get('user', 'account', { categories: ['income', 'tax'] });
|
||||||
|
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a cached page bypass account ownership checks', async () => {
|
||||||
|
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||||
|
await service.get('user', 'account', query);
|
||||||
|
tbank.getOpenAccounts.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
||||||
|
expect.any(NotFoundException),
|
||||||
|
);
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
||||||
|
for (let index = 0; index < 100; index += 1)
|
||||||
|
await service.get('user', 'account', { cursor: String(index) });
|
||||||
|
|
||||||
|
await service.get('user', 'account', { cursor: '0' });
|
||||||
|
await service.get('user', 'account', { cursor: '100' });
|
||||||
|
await service.get('user', 'account', { cursor: '1' });
|
||||||
|
|
||||||
|
const pages = (service as unknown as { pages: { size: number } }).pages;
|
||||||
|
expect(pages.size).toBe(100);
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import { Environment } from '../config/env.validation';
|
import { Environment } from '../config/env.validation';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -20,6 +21,7 @@ import { TbankClient } from './tbank.client';
|
|||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
|
||||||
const freshForMs = 30_000;
|
const freshForMs = 30_000;
|
||||||
|
const maxCachedPages = 100;
|
||||||
const supportedAccounts = new Set([
|
const supportedAccounts = new Set([
|
||||||
'ACCOUNT_TYPE_TINKOFF',
|
'ACCOUNT_TYPE_TINKOFF',
|
||||||
'ACCOUNT_TYPE_TINKOFF_IIS',
|
'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||||
@@ -30,10 +32,11 @@ const supportedAccounts = new Set([
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageOperationsService {
|
export class BrokerageOperationsService {
|
||||||
private readonly crypto: TbankCrypto;
|
private readonly crypto: TbankCrypto;
|
||||||
private readonly pages = new Map<
|
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
|
||||||
string,
|
max: maxCachedPages,
|
||||||
{ page: BrokerageOperationsPageDto; storedAt: number }
|
ttl: freshForMs,
|
||||||
>();
|
ttlAutopurge: true,
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -53,10 +56,6 @@ export class BrokerageOperationsService {
|
|||||||
): Promise<BrokerageOperationsPageDto> {
|
): Promise<BrokerageOperationsPageDto> {
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
const range = this.range(query);
|
const range = this.range(query);
|
||||||
const key = JSON.stringify({ userId, accountId, ...query, ...range });
|
|
||||||
const cached = this.pages.get(key);
|
|
||||||
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
|
||||||
return cached.page;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connection = await this.prisma.tbankConnection.findUnique({
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
@@ -74,9 +73,13 @@ export class BrokerageOperationsService {
|
|||||||
)
|
)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
|
|
||||||
|
const key = cacheKey(userId, accountId, query, range);
|
||||||
|
const cached = this.pages.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
if (range.from > range.to) {
|
if (range.from > range.to) {
|
||||||
const page = emptyPage(accountId);
|
const page = emptyPage(accountId);
|
||||||
this.pages.set(key, { page, storedAt: Date.now() });
|
this.pages.set(key, page);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +104,7 @@ export class BrokerageOperationsService {
|
|||||||
hasNext: Boolean(response.hasNext),
|
hasNext: Boolean(response.hasNext),
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.pages.set(key, { page, storedAt: Date.now() });
|
this.pages.set(key, page);
|
||||||
return page;
|
return page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
@@ -121,6 +124,27 @@ export class BrokerageOperationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cacheKey(
|
||||||
|
userId: string,
|
||||||
|
accountId: string,
|
||||||
|
query: BrokerageOperationsQueryDto,
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
): string {
|
||||||
|
const categories = [...new Set(query.categories ?? [])].sort();
|
||||||
|
const end = query.to
|
||||||
|
? range.to.toISOString()
|
||||||
|
: Math.floor(range.to.valueOf() / freshForMs);
|
||||||
|
return JSON.stringify({
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
from: range.from.toISOString(),
|
||||||
|
to: end,
|
||||||
|
categories,
|
||||||
|
cursor: query.cursor ?? null,
|
||||||
|
limit: query.limit ?? 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function dateBoundary(value: string | undefined, end: boolean): Date | null {
|
function dateBoundary(value: string | undefined, end: boolean): Date | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||||
|
|||||||
Reference in New Issue
Block a user