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,10 +1,10 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankMoney,
TbankOperationItem,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
BrokerageOperationDto,
OperationCategory,
@@ -59,14 +59,14 @@ export function mapBrokerageOperation(
logoName: null,
payment: money(operation.payment),
quantity:
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity) === undefined
? null
: String(
operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity,
(operationCategory(operation.type) === 'trade' ?
operation.quantityDone :
operation.quantity) === undefined ?
null :
String(
operationCategory(operation.type) === 'trade' ?
operation.quantityDone :
operation.quantity,
),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
@@ -74,22 +74,22 @@ export function mapBrokerageOperation(
};
}
function text(value: string | undefined): string | null {
function text(value: string | undefined): null | string {
return value?.trim() || null;
}
function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
return currency && value ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
}
export function operationTimestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
value: undefined | { nanos?: number; seconds?: number | string },
) {
return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
);
}
@@ -5,46 +5,48 @@ import {
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { Prisma } from '../../../../../generated/prisma/client';
import type {
TbankOperationItem,
TbankOperationsByCursorResponse,
} from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
DateOnlyRange,
dateOnlyBounds,
DateOnlyRange,
moscowDate,
} from '../../investment-calendar';
import {
operationCategory,
operationTimestamp,
} from './brokerage-operation.mapper';
import {
type OperationInterval,
operationIntervals,
operationIntervalsOverlap,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
import {
operationCategory,
operationTimestamp,
} from './brokerage-operation.mapper';
export type { OperationInterval } from '../domain/operations-sync-plan';
export type OperationAttempt = {
id: string;
attemptId: string;
userId: string;
id: string;
revision: string;
userId: string;
};
type OperationsIntent = {
intervals: string[];
atomic: boolean;
atomicIntervals?: string[];
intervals: string[];
screenIntervals?: string[];
};
type PageProgress = {
complete: boolean;
cursor: string;
seen: string[];
pages: number;
seen: string[];
};
const progressItem = '';
@@ -53,17 +55,17 @@ export class BrokerageOperationsRepository {
constructor(private readonly prisma: PrismaService) {}
async readPage(input: {
userId: string;
revision: string;
accountId: string;
from: Date;
to: Date;
instrumentUid?: string;
categories: string[];
cursor?: { accountId: string; at: string; binding: string; id: string };
filterKey: string;
from: Date;
instrumentUid?: string;
limit?: number;
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
filterKey: string;
cursor?: { binding: string; at: string; accountId: string; id: string };
revision: string;
to: Date;
userId: string;
}) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findUnique({
@@ -92,14 +94,16 @@ export class BrokerageOperationsRepository {
connection.membershipAt &&
input.accountId !== 'all' &&
!connection.accounts.length
)
) {
throw new NotFoundException();
}
const accounts = connection.accounts;
// 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.
const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
oldest && accounts.every((account) => account.openedOn) ?
new Date(`${oldest}T00:00:00+03:00`) :
undefined;
const periods = input.periods?.(openedAt).map((period) => {
const now = new Date();
const bounds = dateOnlyBounds(period, now);
@@ -123,8 +127,9 @@ export class BrokerageOperationsRepository {
]),
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding)
if (input.cursor && input.cursor.binding !== binding) {
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
}
const scope = {
userId: input.userId,
revision: input.revision,
@@ -162,21 +167,21 @@ export class BrokerageOperationsRepository {
const rows = await tx.tbankOperation.findMany({
where: {
...scope,
...(periods
? {
...(periods ?
{
OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to },
})),
}
: { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid
? { instrumentUid: input.instrumentUid }
: {}),
...(input.categories.length
? { category: { in: input.categories } }
: {}),
...(after
? {
} :
{ occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid ?
{ instrumentUid: input.instrumentUid } :
{}),
...(input.categories.length ?
{ category: { in: input.categories } } :
{}),
...(after ?
{
OR: [
{ occurredAt: { lt: new Date(after.at) } },
{
@@ -189,8 +194,8 @@ export class BrokerageOperationsRepository {
brokerOperationId: { gt: after.id },
},
],
}
: {}),
} :
{}),
},
orderBy: [
{ occurredAt: 'desc' },
@@ -247,6 +252,7 @@ export class BrokerageOperationsRepository {
const scopeKey =
'operations:' +
createHash('sha256')
// 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.
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
.digest('hex');
return this.prisma.$transaction(async (tx) => {
@@ -271,14 +277,15 @@ export class BrokerageOperationsRepository {
(account) => account.accountId === interval.accountId,
),
)
)
) {
throw new NotFoundException();
}
if (priority > 1) {
for (const account of accounts.filter((account) =>
intervals.some(
(interval) => interval.accountId === account.accountId,
),
))
)) {
await tx.tbankAccount.update({
where: {
userId_revision_accountId: {
@@ -289,13 +296,14 @@ export class BrokerageOperationsRepository {
},
data: {
historyFrom: new Date(
account.openedOn
? `${account.openedOn}T00:00:00Z`
: '1970-01-01T00:00:00Z',
account.openedOn ?
`${account.openedOn}T00:00:00Z` :
'1970-01-01T00:00:00Z',
),
historyBoundary: account.openedOn ? 'known' : 'unknown',
},
});
}
}
const coverage = await tx.tbankOperationCoverage.findMany({
where: {
@@ -366,42 +374,42 @@ export class BrokerageOperationsRepository {
) {
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
const additions =
previous.scopeKey === scopeKey
? []
: uncoveredOperationIntervals(intervals, pending);
previous.scopeKey === scopeKey ?
[] :
uncoveredOperationIntervals(intervals, pending);
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
intent: {
atomic: previousIntent?.atomic ?? publicationAtomic,
atomicIntervals: previousIntent?.atomic
? [
atomicIntervals: previousIntent?.atomic ?
[
...new Set([
...(previousIntent.atomicIntervals ??
previousIntent.intervals),
...additions.map(intervalKey),
]),
]
: [],
] :
[],
screenIntervals: [
...new Set([
...(previousIntent?.screenIntervals ??
(previous.priority === 1
? (previousIntent?.intervals ?? [])
: [])),
...(priority === 1
? [...pending, ...additions]
(previous.priority === 1 ?
(previousIntent?.intervals ?? []) :
[])),
...(priority === 1 ?
[...pending, ...additions]
.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
)
.map(intervalKey)
: []),
.map(intervalKey) :
[]),
]),
],
intervals: (priority < previous.priority
? [
intervals: (priority < previous.priority ?
[
...pending.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
@@ -414,8 +422,8 @@ export class BrokerageOperationsRepository {
operationIntervalsOverlap(item, target),
),
),
]
: [...pending, ...additions]
] :
[...pending, ...additions]
).map(intervalKey),
},
priority: Math.min(priority, previous.priority),
@@ -427,9 +435,9 @@ export class BrokerageOperationsRepository {
previous &&
previous.state !== 'complete' &&
previous.state !== 'superseded';
const retained = recovering
? (previousIntent?.intervals.map(decodeInterval) ?? [])
: [];
const retained = recovering ?
(previousIntent?.intervals.map(decodeInterval) ?? []) :
[];
// A bridge between two active groups must fence both producers before
// either can publish a subset of the newly combined result.
for (const joined of touching) {
@@ -448,26 +456,27 @@ export class BrokerageOperationsRepository {
});
await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } });
}
if (previous)
if (previous) {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
}
const targets =
recovering && !upgradeGroup && touching.length <= 1
? previous?.scopeKey === scopeKey
? retained
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
: operationIntervals([...retained, ...intervals]);
recovering && !upgradeGroup && touching.length <= 1 ?
previous?.scopeKey === scopeKey ?
retained :
[...retained, ...uncoveredOperationIntervals(intervals, retained)] :
operationIntervals([...retained, ...intervals]);
const atomicTargets = [
...(publicationAtomic ? intervals : []),
...(recovering && previousIntent?.atomic
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
...(recovering && previousIntent?.atomic ?
(previousIntent.atomicIntervals ?? previousIntent.intervals).map(
decodeInterval,
)
: []),
) :
[]),
...touching.flatMap((work) => {
const joined = work.intent as OperationsIntent;
return joined.atomic
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
: [];
return joined.atomic ?
(joined.atomicIntervals ?? joined.intervals).map(decodeInterval) :
[];
}),
];
const intent = {
@@ -486,14 +495,14 @@ export class BrokerageOperationsRepository {
intervals.some((incoming) =>
operationIntervalsOverlap(target, incoming),
)) ||
(
previousIntent?.screenIntervals ??
(previous?.priority === 1
? (previousIntent?.intervals ?? [])
: [])
).some((key) =>
operationIntervalsOverlap(target, decodeInterval(key)),
),
(
previousIntent?.screenIntervals ??
(previous?.priority === 1 ?
(previousIntent?.intervals ?? []) :
[])
).some((key) =>
operationIntervalsOverlap(target, decodeInterval(key)),
),
)
.map(intervalKey),
atomic: atomicTargets.length > 0,
@@ -545,20 +554,20 @@ export class BrokerageOperationsRepository {
marker.payload as PageProgress,
]),
);
const group = intent.atomic
? (intent.atomicIntervals ?? intent.intervals)
: intent.intervals;
const group = intent.atomic ?
(intent.atomicIntervals ?? intent.intervals) :
intent.intervals;
const nextKey = group.find((key) => !progress.get(key)?.complete);
return {
priority: work.priority,
atomic: intent.atomic,
intervals: group.map(decodeInterval),
next: nextKey
? {
next: nextKey ?
{
interval: decodeInterval(nextKey),
cursor: progress.get(nextKey)?.cursor || undefined,
}
: null,
} :
null,
};
}
@@ -582,8 +591,9 @@ export class BrokerageOperationsRepository {
updatedAt: true,
},
});
if (!work || work.state !== 'superseded')
if (!work || work.state !== 'superseded') {
return work ? { ...work, id } : null;
}
target = (work.progress as { successorId: string }).successorId;
}
throw new Error('Cyclic operations work successor');
@@ -599,7 +609,7 @@ export class BrokerageOperationsRepository {
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
},
});
if (invalidToken)
if (invalidToken) {
await tx.tbankConnection.update({
where: {
userId_revision: {
@@ -609,6 +619,7 @@ export class BrokerageOperationsRepository {
},
data: { invalidToken: true },
});
}
return true;
});
}
@@ -636,34 +647,40 @@ export class BrokerageOperationsRepository {
},
});
const progress = marker?.payload as PageProgress | undefined;
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? ''))
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) {
throw new Error('Unexpected operations page');
if (response.hasNext === undefined)
}
if (response.hasNext === undefined) {
throw new Error('Missing operations pagination state');
}
const nextCursor = response.nextCursor ?? '';
if (
response.hasNext &&
(!nextCursor ||
nextCursor === cursor ||
progress?.seen.includes(nextCursor))
)
) {
throw new Error('Invalid operations continuation');
}
for (const operation of response.items ?? []) {
if (!operation.id?.trim())
if (!operation.id?.trim()) {
throw new Error('Missing broker operation identity');
}
const occurredAt = operationTimestamp(operation.date);
if (
operation.date?.seconds === undefined ||
!Number.isFinite(occurredAt.valueOf())
)
) {
throw new Error('Invalid operation timestamp');
}
if (
occurredAt < interval.from ||
occurredAt >= interval.to ||
(interval.instrumentUid &&
operation.instrumentUid !== interval.instrumentUid)
)
) {
continue;
}
const payload = JSON.parse(
JSON.stringify({ ...operation, cursor: undefined }),
) as Prisma.InputJsonObject;
@@ -672,10 +689,12 @@ export class BrokerageOperationsRepository {
const previous = await tx.tbankSyncStage.findUnique({
where: { workId_attemptId_sourceKey_itemKey: identity },
});
if (previous && !isDeepStrictEqual(previous.payload, payload))
if (previous && !isDeepStrictEqual(previous.payload, payload)) {
throw new Error('Conflicting broker operation identity');
if (!previous)
}
if (!previous) {
await tx.tbankSyncStage.create({ data: { ...identity, payload } });
}
}
const payload: PageProgress = {
complete: !response.hasNext,
@@ -712,8 +731,9 @@ export class BrokerageOperationsRepository {
fetchedAt: Date,
advanceWork = false,
): Promise<boolean> {
if (!intervals.length)
if (!intervals.length) {
throw new Error('Empty operations publication group');
}
return this.prisma.$transaction(
async (tx) => {
if (!(await claim(tx, attempt))) return false;
@@ -734,8 +754,9 @@ export class BrokerageOperationsRepository {
},
},
});
if (!(marker?.payload as PageProgress | undefined)?.complete)
if (!(marker?.payload as PageProgress | undefined)?.complete) {
throw new Error('Incomplete operations publication group');
}
const staged = await tx.tbankSyncStage.findMany({
where: { ...stage, itemKey: { not: progressItem } },
});
@@ -745,10 +766,11 @@ export class BrokerageOperationsRepository {
if (
previous !== undefined &&
!isDeepStrictEqual(previous, row.payload)
)
) {
throw new Error(
'Conflicting broker operation identity across intervals',
);
}
groupRows.set(identity, row.payload);
}
const rows = staged.filter((row) => {
@@ -764,16 +786,16 @@ export class BrokerageOperationsRepository {
where: {
...scope,
occurredAt: { gte: interval.from, lt: interval.to },
...(interval.instrumentUid
? { instrumentUid: interval.instrumentUid }
: {}),
...(interval.instrumentUid ?
{ instrumentUid: interval.instrumentUid } :
{}),
},
});
const incoming = new Set(rows.map((row) => row.itemKey));
const removed = existing.filter(
(row) => !incoming.has(row.brokerOperationId),
);
for (const row of removed)
for (const row of removed) {
await tx.tbankOperation.delete({
where: {
userId_revision_accountId_brokerOperationId: {
@@ -782,6 +804,7 @@ export class BrokerageOperationsRepository {
},
},
});
}
if (removed.length) changedAccounts.add(interval.accountId);
for (const row of rows) {
const operation = row.payload as TbankOperationItem;
@@ -789,8 +812,9 @@ export class BrokerageOperationsRepository {
const previous = await tx.tbankOperation.findUnique({
where: { userId_revision_accountId_brokerOperationId: identity },
});
if (previous && isDeepStrictEqual(previous.payload, operation))
if (previous && isDeepStrictEqual(previous.payload, operation)) {
continue;
}
const data = {
occurredAt: operationTimestamp(operation.date),
instrumentUid: operation.instrumentUid ?? null,
@@ -818,7 +842,7 @@ export class BrokerageOperationsRepository {
update: { fetchedAt, asOf: interval.to },
});
}
for (const accountId of changedAccounts)
for (const accountId of changedAccounts) {
await tx.tbankAccount.update({
where: {
userId_revision_accountId: {
@@ -829,7 +853,8 @@ export class BrokerageOperationsRepository {
},
data: { operationsRevision: { increment: 1 } },
});
for (const interval of intervals)
}
for (const interval of intervals) {
await tx.tbankSyncStage.deleteMany({
where: {
workId: attempt.id,
@@ -837,6 +862,7 @@ export class BrokerageOperationsRepository {
sourceKey: intervalKey(interval),
},
});
}
if (advanceWork) {
const work = await tx.tbankSyncWork.findUniqueOrThrow({
where: { id: attempt.id },
@@ -867,9 +893,9 @@ export class BrokerageOperationsRepository {
intent.screenIntervals ??
(work.priority === 1 ? intent.intervals : [])
).includes(key),
)
? 1
: 2,
) ?
1 :
2,
progress: { remainingIntervals: remaining.length },
},
});
@@ -882,8 +908,10 @@ export class BrokerageOperationsRepository {
}
function intervalKey(interval: OperationInterval): string {
if (!(interval.from < interval.to))
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) {
throw new Error('Invalid operations interval');
}
return JSON.stringify([
interval.accountId,
interval.from.toISOString(),