chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import {
ForecastRepository,
type ForecastSourceVersion,
@@ -12,16 +14,16 @@ export class ForecastService {
constructor(private readonly repository: ForecastRepository) {}
async result<T extends object>(input: {
userId: string;
revision: string;
membershipRevision: number;
scopeKey: string;
portfolio: InvestmentResourceMetaDto;
sources: {
version: ForecastSourceVersion | null;
meta: InvestmentResourceMetaDto;
}[];
candidate?: T;
membershipRevision: number;
portfolio: InvestmentResourceMetaDto;
revision: string;
scopeKey: string;
sources: {
meta: InvestmentResourceMetaDto;
version: ForecastSourceVersion | null;
}[];
userId: string;
}) {
const dependencies = [
input.portfolio,
@@ -31,8 +33,8 @@ export class ForecastService {
...input,
portfolioProjection: input.portfolio.dataRevision,
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
dependency.resourceKey.startsWith('portfolio/')
? [
dependency.resourceKey.startsWith('portfolio/') ?
[
{
accountId: decodeURIComponent(
dependency.resourceKey.slice('portfolio/'.length),
@@ -41,65 +43,65 @@ export class ForecastService {
(dependency as InvestmentResourceMetaDto).dataRevision,
),
},
]
: [],
] :
[],
),
sources: input.sources.flatMap((source) =>
source.version ? [source.version] : [],
),
timestamps: dependencies.flatMap((dependency) =>
dependency.lastSuccessfulAt
? [
dependency.lastSuccessfulAt ?
[
{
resourceKey: dependency.resourceKey,
at: dependency.lastSuccessfulAt,
},
]
: [],
] :
[],
),
});
const timestamps = stored?.sourceTimestamps as
{ resourceKey: string; at: string }[] | undefined;
undefined | { at: string; resourceKey: string }[];
const first = timestamps?.map((entry) => entry.at).sort()[0];
const work =
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'running') ??
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
const payload = stored ? (stored.payload as T) : null;
const data =
payload &&
'positionIsStale' in payload &&
'positionAt' in payload &&
typeof payload.positionAt === 'string'
? {
typeof payload.positionAt === 'string' ?
{
...payload,
positionIsStale: tbankSourceNeedsRefresh(
'portfolio',
new Date(payload.positionAt),
new Date(Date.now()),
),
}
: payload;
} :
payload;
return {
data,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? 0),
availability: stored
? 'complete'
: dependencies.some(
(dependency) => dependency.availability !== 'missing',
)
? 'partial'
: 'missing',
availability: stored ?
'complete' :
dependencies.some(
(dependency) => dependency.availability !== 'missing',
) ?
'partial' :
'missing',
fetchedAt: first ? new Date(first) : null,
freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true
? 0
: Number.POSITIVE_INFINITY,
data && 'positionIsStale' in data && data.positionIsStale === true ?
0 :
Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null,
dependencies,
}),
@@ -1,10 +1,10 @@
import { setImmediate as immediate } from 'node:timers/promises';
import { ServiceUnavailableException } from '@nestjs/common';
import { setImmediate as immediate } from 'node:timers/promises';
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { PortfolioService } from './portfolio.service';
import { PortfolioDto } from '../presentation/portfolio.dto';
import { PortfolioService } from './portfolio.service';
describe('persistent portfolio projection', () => {
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
@@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => {
let index = 0;
index < 10 && !repository.publish.mock.calls.length;
index += 1
)
) {
await immediate();
}
const snapshot = (
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
)[0][2];
@@ -1,17 +1,10 @@
import { CandleService } from '../../instruments/instruments.api';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
PortfolioRepository,
portfolioFreshForMs,
portfolioWorkKey,
} from '../infrastructure/portfolio.repository';
import {
Injectable,
ServiceUnavailableException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
add,
decimalFromString,
@@ -21,22 +14,30 @@ import {
subtract,
} from '../../../../common/decimal';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../../common/money.dto';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
InstrumentMetadataService,
metadataKey,
type TbankInstrument,
type MetadataIdentity,
metadataKey,
TbankAccessService,
TbankClient,
TbankMoney,
type TbankInstrument,
TbankMembershipService,
TbankMoney,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { portfolioStructure } from '../domain/portfolio-structure';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import {
portfolioFreshForMs,
PortfolioRepository,
portfolioWorkKey,
} from '../infrastructure/portfolio.repository';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
import {
PortfolioDto,
PortfolioPositionDto,
@@ -80,7 +81,7 @@ export class PortfolioService {
userId,
connection.revision,
);
if (discovery)
if (discovery) {
void discovery.done
.then(async () => {
const current = await this.access.local(userId, accountId);
@@ -88,12 +89,13 @@ export class PortfolioService {
for (const account of current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
))
)) {
await this.queueSnapshot(
userId,
current.revision,
account.accountId,
);
}
})
.catch(() =>
this.logger.event(
@@ -102,22 +104,25 @@ export class PortfolioService {
{ userId },
),
);
}
for (const account of (discovery ? [] : connection.accounts).filter(
(account) => accountId === 'all' || account.accountId === accountId,
))
)) {
await this.queueSnapshot(
userId,
connection.revision,
account.accountId,
);
}
}
const saved = await this.repository.read(userId, connection.revision);
if (
saved?.membershipAt &&
accountId !== 'all' &&
!saved.accounts.some((account) => account.accountId === accountId)
)
) {
throw new NotFoundException();
}
if (!observe) {
const identities = new Map<string, MetadataIdentity>();
for (const account of (saved?.accounts ?? []).filter(
@@ -125,17 +130,20 @@ export class PortfolioService {
)) {
const snapshot = account.portfolio?.payload as unknown as
PortfolioDto | undefined;
for (const position of snapshot?.positions ?? [])
for (const position of snapshot?.positions ?? []) {
identities.set(metadataKey(position), position);
}
}
const savedMetadata = await this.metadata.savedMany([
...identities.values(),
]);
for (const identity of identities.values())
if (!savedMetadata.get(metadataKey(identity))?.fresh)
for (const identity of identities.values()) {
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
if (!observe)
if (!observe) {
void this.candles
.prefetch(
userId,
@@ -156,6 +164,7 @@ export class PortfolioService {
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
}
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? 0),
@@ -175,15 +184,15 @@ export class PortfolioService {
name: account.name,
type: account.type,
openedOn: account.openedOn,
snapshot: account.portfolio
? {
snapshot: account.portfolio ?
{
...(account.portfolio.payload as unknown as PortfolioDto),
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
isStale:
Date.now() - account.portfolio.fetchedAt.valueOf() >=
portfolioFreshForMs,
}
: null,
} :
null,
meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
dataRevision: String(account.portfolio?.dataRevision ?? 0),
@@ -209,8 +218,8 @@ export class PortfolioService {
);
const metadataDependencies = [...metadata.entries()].flatMap(
([key, source]) =>
source.complete && source.data
? [
source.complete && source.data ?
[
investmentResourceMeta({
resourceKey: `instrument/metadata/${key}`,
dataRevision: source.dataRevision,
@@ -219,20 +228,20 @@ export class PortfolioService {
freshForMs: tbankSourceTtlMs.metadata,
work: null,
}),
]
: [],
] :
[],
);
const portfolios = saved.accounts.flatMap((account) =>
account.snapshot
? [
account.snapshot ?
[
{
...account.snapshot,
positions: account.snapshot.positions.map((position) => {
const shared = metadata.get(metadataKey(position))?.data;
return {
...position,
...(shared
? {
...(shared ?
{
displayName:
shared.name?.trim() ||
shared.ticker?.trim() ||
@@ -241,28 +250,29 @@ export class PortfolioService {
isin: shared.isin?.trim() || position.isin,
logoName:
shared.brand?.logoName?.trim() || position.logoName,
}
: {}),
...(accountId === 'all'
? { account: { id: account.id, name: account.name } }
: {}),
} :
{}),
...(accountId === 'all' ?
{ account: { id: account.id, name: account.name } } :
{}),
};
}),
},
]
: [],
] :
[],
);
const lastSuccessfulAt =
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
saved.membership.lastSuccessfulAt;
const data =
missing || !lastSuccessfulAt
? null
: {
missing || !lastSuccessfulAt ?
null :
{
lastSuccessfulAt,
isStale: portfolios.length
? portfolios.some((portfolio) => portfolio.isStale)
: saved.membership.freshness !== 'fresh',
isStale: portfolios.length ?
portfolios.some((portfolio) => portfolio.isStale) :
saved.membership.freshness !== 'fresh',
totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
),
@@ -279,10 +289,10 @@ export class PortfolioService {
saved.accounts
.map((account) => account.meta.sync)
.find((work) => work.state === 'running') ??
saved.accounts
.map((account) => account.meta.sync)
.find((work) => work.state === 'failed') ??
saved.membership.sync;
saved.accounts
.map((account) => account.meta.sync)
.find((work) => work.state === 'failed') ??
saved.membership.sync;
return {
data,
meta: investmentResourceMeta({
@@ -294,15 +304,15 @@ export class PortfolioService {
]),
metadataDependencies.map((dependency) => dependency.dataRevision),
]),
availability: missing
? portfolios.length
? 'partial'
: 'missing'
: 'complete',
availability: missing ?
portfolios.length ?
'partial' :
'missing' :
'complete',
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
freshForMs: portfolios.length
? portfolioFreshForMs
: membershipFreshForMs,
freshForMs: portfolios.length ?
portfolioFreshForMs :
membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null,
dependencies: [
saved.membership,
@@ -333,11 +343,12 @@ export class PortfolioService {
force,
);
if (!requested) return null;
if (!requested.launch)
if (!requested.launch) {
return {
id: requested.work.id,
done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(),
};
}
const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve))
@@ -360,22 +371,24 @@ export class PortfolioService {
snapshot,
);
if (published) {
for (const position of snapshot.positions)
for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position);
if (!force)
}
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
}
} catch (error) {
if (
error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN'
)
) {
await this.access.rejectMembershipToken(attempt);
else await this.repository.fail(attempt);
} else await this.repository.fail(attempt);
}
})
.catch(() =>
@@ -396,8 +409,9 @@ export class PortfolioService {
async get(userId: string, accountId: string): Promise<PortfolioDto> {
const result = await this.read(userId, accountId);
if (!result.data)
if (!result.data) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
return result.data;
}
@@ -407,12 +421,12 @@ export class PortfolioService {
private async normalize(
upstream: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: string | number };
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: { nano?: number | string; units?: number | string };
}[],
money: TbankMoney[],
): Promise<PortfolioDto> {
@@ -450,8 +464,8 @@ export class PortfolioService {
private position(
position: Parameters<PortfolioService['normalize']>[0][number],
metadata: TbankInstrument | null,
): PortfolioPositionDto | null {
metadata: null | TbankInstrument,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
@@ -462,8 +476,8 @@ export class PortfolioService {
const absoluteReturn =
currentPrice &&
averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency
? {
currentPrice.currency === averagePurchasePrice.currency ?
{
currency: currentPrice.currency,
value: formatDecimal(
multiply(
@@ -474,8 +488,8 @@ export class PortfolioService {
quantity,
),
),
}
: null;
} :
null;
return {
figi,
instrumentUid,
@@ -507,8 +521,8 @@ export class PortfolioService {
private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
return currency && value ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
}
}