feat: migrate services to ApiEnvelopePayload, controllers to plain returns

This commit is contained in:
Sergey Krylov 2026-06-24 19:39:30 +03:00
parent 3a89cef76c
commit b092caf8d6
13 changed files with 82 additions and 154 deletions

View File

@ -41,13 +41,7 @@ export class AuthController {
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -57,13 +51,7 @@ export class AuthController {
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -75,13 +63,7 @@ export class AuthController {
const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Post('logout')
@ -92,10 +74,7 @@ export class AuthController {
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
return { message: 'Logged out successfully' };
}
@Get('me')
@ -103,11 +82,7 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.getProfile(user.sub);
}
@Patch('me')
@ -115,10 +90,6 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.updateProfile(user.sub, dto);
}
}

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class BondsService {
@ -32,8 +33,8 @@ export class BondsService {
'marketDataTtl',
);
return {
data: {
return new ApiEnvelopePayload(
{
secid: bond.secid,
isin: bond.isin,
name: bond.shortName,
@ -70,8 +71,9 @@ export class BondsService {
: new Date().toISOString(),
},
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getMarketData(secid: string) {
@ -90,8 +92,8 @@ export class BondsService {
throw new NotFoundException(`Market data for bond ${secid} not found`);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null,
@ -107,8 +109,9 @@ export class BondsService {
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
@ -119,14 +122,15 @@ export class BondsService {
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? null,
duration: h.duration ?? null,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class CandlesService {
@ -29,8 +30,8 @@ export class CandlesService {
'candlesTtl',
);
return {
data: data.map((c) => ({
return new ApiEnvelopePayload(
data.map((c) => ({
open: c.open,
high: c.high,
low: c.low,
@ -40,7 +41,8 @@ export class CandlesService {
begin: c.begin,
end: c.end,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -26,12 +26,8 @@ import {
const nullDataEnvelopeSchema = {
type: 'object',
properties: {
data: {
type: 'null',
},
meta: {
$ref: getSchemaPath(PortfolioResponseMetaDto),
},
data: { type: 'null' },
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
},
required: ['data', 'meta'],
};
@ -47,24 +43,21 @@ export class PortfolioController {
@ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) {
const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.findAll(user.sub);
}
@Post()
@ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
const portfolio = await this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.create(user.sub, dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.findOne(user.sub, id);
}
@Patch(':id')
@ -75,8 +68,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePortfolioDto,
) {
const portfolio = await this.portfolioService.update(user.sub, id, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.update(user.sub, id, dto);
}
@Delete(':id')
@ -84,7 +76,7 @@ export class PortfolioController {
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id);
return { data: null, meta: { cachedAt: null, fromCache: false } };
return null;
}
@Post(':id/positions')
@ -95,8 +87,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number,
@Body() dto: AddPositionDto,
) {
const position = await this.portfolioService.addPosition(user.sub, id, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.addPosition(user.sub, id, dto);
}
@Patch(':id/positions/:positionId')
@ -108,16 +99,14 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number,
@Body() dto: UpdatePositionDto,
) {
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
}
@Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const result = await this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.getAnalytics(user.sub, id);
}
@Delete(':id/positions/:positionId')
@ -129,6 +118,6 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number,
) {
await this.portfolioService.removePosition(user.sub, id, positionId);
return { data: null, meta: { cachedAt: null, fromCache: false } };
return null;
}
}

View File

@ -21,19 +21,17 @@ export class SecuritiesController {
@ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search(
return this.securitiesService.search(
query.q,
query.type || SecurityType.ALL,
query.limit || 20,
);
return { data: results, meta: { cachedAt: null, fromCache: false } };
}
@Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
return this.screenerService.screen(query);
}
}

View File

@ -19,8 +19,7 @@ export class SharesController {
@ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
return this.sharesService.getShare(secid);
}
@Get(':secid/marketdata')

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class SharesService {
@ -77,8 +78,8 @@ export class SharesService {
throw new NotFoundException(`Market data for ${secid} not found`);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: marketData.last ?? 0,
change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0,
@ -92,8 +93,9 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getDividends(secid: string) {
@ -104,14 +106,15 @@ export class SharesService {
'dividendsTtl',
);
return {
data: data.map((d) => ({
return new ApiEnvelopePayload(
data.map((d) => ({
registryCloseDate: d.registryCloseDate,
value: d.value,
currency: d.currencyId,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
@ -122,8 +125,8 @@ export class SharesService {
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
open: h.open ?? 0,
high: h.high ?? 0,
@ -132,7 +135,8 @@ export class SharesService {
volume: h.volume,
value: h.value,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable()
export class BrokerAccountsService {
@ -13,10 +14,7 @@ export class BrokerAccountsService {
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'],
@ -24,10 +22,7 @@ export class BrokerAccountsService {
'tbankAccountsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
async findById(accountId: string): Promise<BrokerAccount | null> {

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
@ -41,10 +42,7 @@ export class BrokerAnalyticsService {
private readonly cacheService: CacheService,
) {}
async getAnalytics(accountId: string): Promise<{
data: BrokerAnalyticsDto;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -55,10 +53,7 @@ export class BrokerAnalyticsService {
'tbankAnalyticsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper';
import type {
@ -52,10 +53,7 @@ export class BrokerEventsService {
async getEvents(
accountId: string,
query: BrokerEventsQuery,
): Promise<{
data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -68,10 +66,7 @@ export class BrokerEventsService {
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async buildEvents(

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
@ -19,10 +20,7 @@ export class BrokerOperationsService {
async getOperations(
accountId: string,
query: BrokerOperationQueryDto,
): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -34,10 +32,7 @@ export class BrokerOperationsService {
'tbankOperationsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {

View File

@ -12,6 +12,7 @@ import type {
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable()
export class BrokerPortfolioService {
@ -22,10 +23,7 @@ export class BrokerPortfolioService {
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -43,10 +41,7 @@ export class BrokerPortfolioService {
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
async getPositions(
@ -54,10 +49,7 @@ export class BrokerPortfolioService {
cursor?: string,
limit = 10,
type?: string,
): Promise<{
data: BrokerPositionsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -87,10 +79,7 @@ export class BrokerPortfolioService {
'tbankPositionsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async buildInstrumentMap(

View File

@ -1,6 +1,5 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import {
BrokerAccountsEnvelopeDto,
@ -40,16 +39,14 @@ export class TBankController {
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() {
const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerAccountsService.findAll();
}
@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerPortfolioService.getPortfolio(accountId);
}
@Get('accounts/:accountId/positions')
@ -59,13 +56,12 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerPositionQueryDto,
) {
const result = await this.brokerPortfolioService.getPositions(
return this.brokerPortfolioService.getPositions(
accountId,
query.cursor,
query.limit,
query.type,
);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Get('accounts/:accountId/operations')
@ -75,24 +71,21 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto,
) {
const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerOperationsService.getOperations(accountId, query);
}
@Get('accounts/:accountId/events')
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
const result = await this.brokerEventsService.getEvents(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerEventsService.getEvents(accountId, query);
}
@Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
async getAnalytics(@Param('accountId') accountId: string) {
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerAnalyticsService.getAnalytics(accountId);
}
@Post('accounts/:accountId/operations/sync')
@ -102,7 +95,6 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto,
) {
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
return this.brokerOperationSyncService.syncAccount(accountId, query);
}
}