diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 68c58bf..d933b34 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -1,10 +1,21 @@ import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, +} from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { + AuthLogoutResponseDto, + AuthProfileResponseDto, + AuthTokenResponseDto, +} from './dto/auth-response.dto'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; import { JwtPayload } from './interfaces/jwt-payload.interface'; @@ -26,6 +37,7 @@ export class AuthController { @Public() @Post('register') @ApiOperation({ summary: 'Register new user' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) 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); @@ -41,6 +53,7 @@ export class AuthController { @Public() @Post('login') @ApiOperation({ summary: 'Login with email and password' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) 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); @@ -57,6 +70,7 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Refresh access token' }) + @ApiOkResponse({ type: AuthTokenResponseDto }) async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { const token = req.cookies?.[REFRESH_COOKIE]; const result = await this.authService.refresh(token); @@ -74,6 +88,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiBearerAuth() @ApiOperation({ summary: 'Logout user' }) + @ApiOkResponse({ type: AuthLogoutResponseDto }) async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { await this.authService.logout(user.sub); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); @@ -86,6 +101,7 @@ export class AuthController { @Get('me') @ApiBearerAuth() @ApiOperation({ summary: 'Get current user profile' }) + @ApiOkResponse({ type: AuthProfileResponseDto }) async getProfile(@CurrentUser() user: JwtPayload) { const profile = await this.authService.getProfile(user.sub); return { @@ -97,6 +113,7 @@ export class AuthController { @Patch('me') @ApiBearerAuth() @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 { diff --git a/apps/backend/src/modules/auth/dto/auth-response.dto.ts b/apps/backend/src/modules/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..da8885d --- /dev/null +++ b/apps/backend/src/modules/auth/dto/auth-response.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class AuthResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +class AuthUserDto { + @ApiProperty() + id!: number; + + @ApiProperty() + email!: string; + + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @ApiProperty() + role!: string; +} + +class AuthTokenDataDto { + @ApiProperty({ type: AuthUserDto }) + user!: AuthUserDto; + + @ApiProperty() + accessToken!: string; +} + +class LogoutDataDto { + @ApiProperty() + message!: string; +} + +export class AuthTokenResponseDto { + @ApiProperty({ type: AuthTokenDataDto }) + data!: AuthTokenDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthProfileResponseDto { + @ApiProperty({ type: AuthUserDto }) + data!: AuthUserDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthLogoutResponseDto { + @ApiProperty({ type: LogoutDataDto }) + data!: LogoutDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts index 5f8649c..7ed69b3 100644 --- a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -51,7 +51,7 @@ export class AddPositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts index 079eba0..059b678 100644 --- a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts @@ -1,19 +1,19 @@ import { ApiProperty } from '@nestjs/swagger'; -import { EnrichedPosition } from '../portfolio.service'; +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioSummaryDto { @ApiProperty() totalInvested!: number; @ApiProperty() totalValue!: number; @ApiProperty() totalPnl!: number; - @ApiProperty() totalPnlPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null; @ApiProperty() totalDividends!: number; @ApiProperty() totalReturn!: number; - @ApiProperty() totalReturnPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty() positionCount!: number; - @ApiProperty() weightedYield!: number | null; + @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; } export class AnalyticsResponseDto { - @ApiProperty({ type: [Object] }) positions!: EnrichedPosition[]; + @ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[]; @ApiProperty() summary!: PortfolioSummaryDto; } diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts new file mode 100644 index 0000000..37ae555 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AnalyticsResponseDto } from './analytics-response.dto'; +import { PortfolioListResponseDto } from './portfolio-list-response.dto'; +import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; +import { PositionResponseDto } from './position-response.dto'; + +export class PortfolioResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class PortfolioListEnvelopeDto { + @ApiProperty({ type: [PortfolioListResponseDto] }) + data!: PortfolioListResponseDto[]; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioEnvelopeDto { + @ApiProperty({ type: PortfolioResponseDto }) + data!: PortfolioResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioDetailEnvelopeDto { + @ApiProperty({ type: PortfolioDetailResponseDto }) + data!: PortfolioDetailResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PositionEnvelopeDto { + @ApiProperty({ type: PositionResponseDto }) + data!: PositionResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class AnalyticsEnvelopeDto { + @ApiProperty({ type: AnalyticsResponseDto }) + data!: AnalyticsResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts index 3d25f11..1b3d02d 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -1,45 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PortfolioSummaryDto } from './analytics-response.dto'; - -class PositionWithPriceDto { - @ApiProperty() id!: number; - @ApiProperty({ example: 'SBER' }) secid!: string; - @ApiPropertyOptional() shortName!: string | null; - @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string; - @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() buyPrice!: number | null; - @ApiPropertyOptional() buyDate!: string | null; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; - @ApiPropertyOptional() currentPrice!: number | null; - @ApiPropertyOptional() totalCost!: number | null; - @ApiPropertyOptional() currentValue!: number | null; - @ApiProperty() weightPercent!: number; - @ApiPropertyOptional() pnl!: number | null; - @ApiPropertyOptional() pnlPercent!: number | null; - @ApiPropertyOptional() dividendIncome!: number | null; - @ApiPropertyOptional() totalReturn!: number | null; - @ApiPropertyOptional() totalReturnPercent!: number | null; - @ApiPropertyOptional() change!: number | null; - @ApiPropertyOptional() changePercent!: number | null; - @ApiPropertyOptional() yieldToMaturity!: number | null; - @ApiPropertyOptional() duration!: number | null; - @ApiPropertyOptional() couponValue!: number | null; - @ApiPropertyOptional() couponPercent!: number | null; - @ApiPropertyOptional() nextCouponDate!: string | null; - @ApiPropertyOptional() matDate!: string | null; - @ApiPropertyOptional() accruedInt!: number | null; - @ApiPropertyOptional() bid!: number | null; - @ApiPropertyOptional() offer!: number | null; - @ApiPropertyOptional() couponPeriod!: number | null; - @ApiPropertyOptional() bondType!: string | null; - @ApiPropertyOptional() offerDate!: string | null; -} +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioResponseDto { @ApiProperty() id!: number; @ApiProperty() name!: string; - @ApiPropertyOptional() description!: string | null; + @ApiPropertyOptional({ type: String, nullable: true }) description!: string | null; @ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts index 7064ea1..a76bd9a 100644 --- a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts @@ -4,8 +4,8 @@ export class PositionResponseDto { @ApiProperty() id!: number; @ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; + @ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null; + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null; @ApiProperty() portfolioId!: number; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts new file mode 100644 index 0000000..edff465 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class PositionWithPriceDto { + @ApiProperty() + id!: number; + + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiPropertyOptional({ type: String, nullable: true }) + shortName!: string | null; + + @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) + type!: string; + + @ApiProperty({ example: 10 }) + quantity!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + buyPrice!: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + buyDate!: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + notes!: string | null; + + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) + tags!: string[] | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentPrice!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalCost!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentValue!: number | null; + + @ApiProperty() + weightPercent!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnl!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnlPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + dividendIncome!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturn!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturnPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + change?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + yieldToMaturity?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + duration?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponValue?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPercent?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + nextCouponDate?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + matDate?: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + accruedInt?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + bid?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + offer?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPeriod?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + bondType?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + offerDate?: string | null; +} diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts index e168843..2aa0cd7 100644 --- a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -45,7 +45,7 @@ export class UpdatePositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 79dd141..777a1d5 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -1,23 +1,51 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; -import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; -import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiExtraModels, + getSchemaPath, +} from '@nestjs/swagger'; import { PortfolioService } from './portfolio.service'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { + AnalyticsEnvelopeDto, + PortfolioDetailEnvelopeDto, + PortfolioEnvelopeDto, + PortfolioListEnvelopeDto, + PortfolioResponseMetaDto, + PositionEnvelopeDto, +} from './dto/portfolio-envelope.dto'; + +const nullDataEnvelopeSchema = { + type: 'object', + properties: { + data: { + nullable: true, + }, + meta: { + $ref: getSchemaPath(PortfolioResponseMetaDto), + }, + }, + required: ['data', 'meta'], +}; @ApiTags('Portfolios') @ApiBearerAuth() +@ApiExtraModels(PortfolioResponseMetaDto) @Controller('portfolios') export class PortfolioController { constructor(private readonly portfolioService: PortfolioService) {} @Get() @ApiOperation({ summary: 'Get all portfolios for current user' }) - @ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) + @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 } }; @@ -25,6 +53,7 @@ export class PortfolioController { @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 } }; @@ -32,7 +61,7 @@ export class PortfolioController { @Get(':id') @ApiOperation({ summary: 'Get portfolio details with positions and prices' }) - @ApiOkResponse({ type: PortfolioDetailResponseDto }) + @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 } }; @@ -40,6 +69,7 @@ export class PortfolioController { @Patch(':id') @ApiOperation({ summary: 'Update portfolio' }) + @ApiOkResponse({ type: PortfolioEnvelopeDto }) async update( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -51,6 +81,7 @@ export class PortfolioController { @Delete(':id') @ApiOperation({ summary: 'Delete portfolio' }) + @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 } }; @@ -58,6 +89,7 @@ export class PortfolioController { @Post(':id/positions') @ApiOperation({ summary: 'Add position to portfolio' }) + @ApiCreatedResponse({ type: PositionEnvelopeDto }) async addPosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -69,6 +101,7 @@ export class PortfolioController { @Patch(':id/positions/:positionId') @ApiOperation({ summary: 'Update position' }) + @ApiOkResponse({ type: PositionEnvelopeDto }) async updatePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -81,6 +114,7 @@ export class PortfolioController { @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 } }; @@ -88,6 +122,7 @@ export class PortfolioController { @Delete(':id/positions/:positionId') @ApiOperation({ summary: 'Remove position from portfolio' }) + @ApiOkResponse({ schema: nullDataEnvelopeSchema }) async removePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts index f2df21d..3c765d8 100644 --- a/apps/backend/src/modules/securities/dto/screener-response.dto.ts +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -13,13 +13,13 @@ export class ScreenerItemDto { @ApiProperty({ enum: ['share', 'bond'] }) type!: 'share' | 'bond'; - @ApiPropertyOptional({ example: 322.35 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 }) price!: number | null; - @ApiPropertyOptional({ example: 1.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 }) change!: number | null; - @ApiPropertyOptional({ example: 0.36 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 }) changePercent!: number | null; @ApiProperty({ example: 1925163 }) @@ -28,28 +28,28 @@ export class ScreenerItemDto { @ApiProperty({ example: 1 }) listLevel!: number; - @ApiPropertyOptional({ example: 6958336818320 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 }) capitalization!: number | null; - @ApiPropertyOptional({ example: 12.71 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 }) yieldToMaturity!: number | null; - @ApiPropertyOptional({ example: 4.5 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 }) duration!: number | null; - @ApiPropertyOptional({ example: 40.64 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 }) couponValue!: number | null; - @ApiPropertyOptional({ example: 8.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 }) couponPercent!: number | null; - @ApiPropertyOptional({ example: 29.48 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 }) accruedInt!: number | null; - @ApiPropertyOptional({ example: '2027-02-03' }) + @ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' }) matDate!: string | null; - @ApiPropertyOptional({ example: 'ОФЗ-ПД' }) + @ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' }) bondType!: string | null; } @@ -69,3 +69,19 @@ export class ScreenerResultDto { @ApiProperty() totalPages!: number; } + +class ScreenerResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class ScreenerResponseDto { + @ApiProperty({ type: ScreenerResultDto }) + data!: ScreenerResultDto; + + @ApiProperty({ type: ScreenerResponseMetaDto }) + meta!: ScreenerResponseMetaDto; +} diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts index b2a488f..be26469 100644 --- a/apps/backend/src/modules/securities/securities.controller.ts +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -4,7 +4,7 @@ import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto'; -import { ScreenerResultDto } from './dto/screener-response.dto'; +import { ScreenerResponseDto } from './dto/screener-response.dto'; @ApiTags('Securities') @Controller('securities') @@ -27,7 +27,7 @@ export class SecuritiesController { @Get('screener') @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) - @ApiOkResponse({ type: ScreenerResultDto }) + @ApiOkResponse({ type: ScreenerResponseDto }) async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { const result = await this.screenerService.screen(query); return { data: result, meta: { cachedAt: null, fromCache: false } }; diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts index 38caeec..52529a5 100644 --- a/apps/backend/src/openapi-artifacts.spec.ts +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -1,10 +1,14 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { load } = require('js-yaml'); + describe('checked-in OpenAPI artifacts', () => { const rootDir = resolve(process.cwd(), '../..'); const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8'); const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8'); + const openapi = load(openapiYaml) as any; const requiredPaths = [ '/api/v1/auth/register', @@ -31,4 +35,62 @@ describe('checked-in OpenAPI artifacts', () => { expect(openapiYaml).toContain(`${path}:`); } }); + + it('checked-in artifacts do not leak the local alternate codegen port', () => { + expect(frontendTypes).not.toContain('localhost:3001'); + expect(frontendTypes).not.toContain('3001'); + expect(openapiYaml).not.toContain('localhost:3001'); + expect(openapiYaml).not.toContain('3001'); + }); + + it('nullable primitive schemas are typed explicitly', () => { + const screenerItem = openapi.components.schemas.ScreenerItemDto; + + expect(screenerItem.properties.price).toMatchObject({ + type: 'number', + nullable: true, + }); + expect(screenerItem.properties.matDate).toMatchObject({ + type: 'string', + nullable: true, + }); + }); + + it('position tags request schemas are arrays of known tags', () => { + for (const schemaName of ['AddPositionDto', 'UpdatePositionDto']) { + const tags = openapi.components.schemas[schemaName].properties.tags; + + expect(tags).toMatchObject({ + type: 'array', + }); + expect(tags.items.enum).toContain('DIVIDEND'); + } + }); + + it('current auth, screener and portfolio operations have typed JSON responses', () => { + const requiredJsonResponses = [ + ['post', '/api/v1/auth/register', 201], + ['post', '/api/v1/auth/login', 201], + ['post', '/api/v1/auth/refresh', 200], + ['post', '/api/v1/auth/logout', 200], + ['get', '/api/v1/auth/me', 200], + ['patch', '/api/v1/auth/me', 200], + ['get', '/api/v1/securities/screener', 200], + ['get', '/api/v1/portfolios', 200], + ['post', '/api/v1/portfolios', 201], + ['get', '/api/v1/portfolios/{id}', 200], + ['patch', '/api/v1/portfolios/{id}', 200], + ['delete', '/api/v1/portfolios/{id}', 200], + ['post', '/api/v1/portfolios/{id}/positions', 201], + ['patch', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['get', '/api/v1/portfolios/{id}/analytics', 200], + ] as const; + + for (const [method, path, status] of requiredJsonResponses) { + expect( + openapi.paths[path][method].responses[status].content?.['application/json'], + ).toBeDefined(); + } + }); }); diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 86eceb2..9d9d3e2 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -395,12 +395,41 @@ export interface components { /** @example John */ name?: string; }; + AuthUserDto: { + id: number; + email: string; + name: string | null; + role: string; + }; + AuthTokenDataDto: { + user: components['schemas']['AuthUserDto']; + accessToken: string; + }; + AuthResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + AuthTokenResponseDto: { + data: components['schemas']['AuthTokenDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; LoginDto: { /** @example user@example.com */ email: string; /** @example securePass123 */ password: string; }; + LogoutDataDto: { + message: string; + }; + AuthLogoutResponseDto: { + data: components['schemas']['LogoutDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; + AuthProfileResponseDto: { + data: components['schemas']['AuthUserDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; UpdateProfileDto: { /** @example John Doe */ name?: string; @@ -415,31 +444,31 @@ export interface components { /** @enum {string} */ type: 'share' | 'bond'; /** @example 322.35 */ - price?: Record; + price?: number | null; /** @example 1.15 */ - change?: Record; + change?: number | null; /** @example 0.36 */ - changePercent?: Record; + changePercent?: number | null; /** @example 1925163 */ volume: number; /** @example 1 */ listLevel: number; /** @example 6958336818320 */ - capitalization?: Record; + capitalization?: number | null; /** @example 12.71 */ - yieldToMaturity?: Record; + yieldToMaturity?: number | null; /** @example 4.5 */ - duration?: Record; + duration?: number | null; /** @example 40.64 */ - couponValue?: Record; + couponValue?: number | null; /** @example 8.15 */ - couponPercent?: Record; + couponPercent?: number | null; /** @example 29.48 */ - accruedInt?: Record; + accruedInt?: number | null; /** @example 2027-02-03 */ - matDate?: Record; + matDate?: string | null; /** @example ОФЗ-ПД */ - bondType?: Record; + bondType?: string | null; }; ScreenerResultDto: { items: components['schemas']['ScreenerItemDto'][]; @@ -448,10 +477,22 @@ export interface components { pageSize: number; totalPages: number; }; + ScreenerResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + ScreenerResponseDto: { + data: components['schemas']['ScreenerResultDto']; + meta: components['schemas']['ScreenerResponseMetaDto']; + }; + PortfolioResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; PortfolioListResponseDto: { id: number; name: string; - description?: Record; + description?: string | null; /** @default RUB */ currency: string; createdAt: string; @@ -465,6 +506,10 @@ export interface components { /** @description Number of bond positions */ bondCount: number; }; + PortfolioListEnvelopeDto: { + data: components['schemas']['PortfolioListResponseDto'][]; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; CreatePortfolioDto: { /** @example Мой портфель */ name: string; @@ -476,11 +521,24 @@ export interface components { */ currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; }; + PortfolioResponseDto: { + id: number; + name: string; + description?: string | null; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + }; + PortfolioEnvelopeDto: { + data: components['schemas']['PortfolioResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; PositionWithPriceDto: { id: number; /** @example SBER */ secid: string; - shortName?: Record; + shortName?: string | null; /** * @example share * @enum {string} @@ -488,49 +546,49 @@ export interface components { type: 'share' | 'bond'; /** @example 10 */ quantity: number; - buyPrice?: Record; - buyDate?: Record; - notes?: Record; - tags?: Record; - currentPrice?: Record; - totalCost?: Record; - currentValue?: Record; + buyPrice?: number | null; + buyDate?: string | null; + notes?: string | null; + tags?: string[] | null; + currentPrice?: number | null; + totalCost?: number | null; + currentValue?: number | null; weightPercent: number; - pnl?: Record; - pnlPercent?: Record; - dividendIncome?: Record; - totalReturn?: Record; - totalReturnPercent?: Record; - change?: Record; - changePercent?: Record; - yieldToMaturity?: Record; - duration?: Record; - couponValue?: Record; - couponPercent?: Record; - nextCouponDate?: Record; - matDate?: Record; - accruedInt?: Record; - bid?: Record; - offer?: Record; - couponPeriod?: Record; - bondType?: Record; - offerDate?: Record; + pnl?: number | null; + pnlPercent?: number | null; + dividendIncome?: number | null; + totalReturn?: number | null; + totalReturnPercent?: number | null; + change?: number | null; + changePercent?: number | null; + yieldToMaturity?: number | null; + duration?: number | null; + couponValue?: number | null; + couponPercent?: number | null; + nextCouponDate?: string | null; + matDate?: string | null; + accruedInt?: number | null; + bid?: number | null; + offer?: number | null; + couponPeriod?: number | null; + bondType?: string | null; + offerDate?: string | null; }; PortfolioSummaryDto: { totalInvested: number; totalValue: number; totalPnl: number; - totalPnlPercent: Record; + totalPnlPercent: number | null; totalDividends: number; totalReturn: number; - totalReturnPercent: Record; + totalReturnPercent: number | null; positionCount: number; - weightedYield: Record; + weightedYield: number | null; }; PortfolioDetailResponseDto: { id: number; name: string; - description?: Record; + description?: string | null; /** @default RUB */ currency: string; createdAt: string; @@ -539,6 +597,10 @@ export interface components { totalValue: number; analytics: components['schemas']['PortfolioSummaryDto']; }; + PortfolioDetailEnvelopeDto: { + data: components['schemas']['PortfolioDetailResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; UpdatePortfolioDto: { /** @example Мой портфель */ name?: string; @@ -566,9 +628,8 @@ export interface components { * "DIVIDEND", * "GROWTH" * ] - * @enum {string} */ - tags?: + tags?: ( | 'DIVIDEND' | 'GROWTH' | 'DEFENSIVE' @@ -576,7 +637,24 @@ export interface components { | 'BOND' | 'ETF' | 'GOVERNMENT' - | 'CASH'; + | 'CASH' + )[]; + }; + PositionResponseDto: { + id: number; + /** @example SBER */ + secid: string; + /** @example 10 */ + quantity: number; + notes?: string | null; + tags?: string[] | null; + portfolioId: number; + createdAt: string; + updatedAt: string; + }; + PositionEnvelopeDto: { + data: components['schemas']['PositionResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; }; UpdatePositionDto: { /** @example 15 */ @@ -591,9 +669,8 @@ export interface components { * @example [ * "DIVIDEND" * ] - * @enum {string} */ - tags?: + tags?: ( | 'DIVIDEND' | 'GROWTH' | 'DEFENSIVE' @@ -601,7 +678,16 @@ export interface components { | 'BOND' | 'ETF' | 'GOVERNMENT' - | 'CASH'; + | 'CASH' + )[]; + }; + AnalyticsResponseDto: { + positions: components['schemas']['PositionWithPriceDto'][]; + summary: components['schemas']['PortfolioSummaryDto']; + }; + AnalyticsEnvelopeDto: { + data: components['schemas']['AnalyticsResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; }; }; responses: never; @@ -646,7 +732,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -667,7 +755,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -684,7 +774,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -701,7 +793,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthLogoutResponseDto']; + }; }; }; }; @@ -718,7 +812,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; }; }; }; @@ -739,7 +835,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; }; }; }; @@ -803,7 +901,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ScreenerResultDto']; + 'application/json': components['schemas']['ScreenerResponseDto']; }; }; }; @@ -1007,7 +1105,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PortfolioListResponseDto'][]; + 'application/json': components['schemas']['PortfolioListEnvelopeDto']; }; }; }; @@ -1029,7 +1127,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; }; }; }; @@ -1049,7 +1149,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PortfolioDetailResponseDto']; + 'application/json': components['schemas']['PortfolioDetailEnvelopeDto']; }; }; }; @@ -1069,7 +1169,12 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': { + data: unknown; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; }; }; }; @@ -1092,7 +1197,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; }; }; }; @@ -1115,7 +1222,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; }; }; }; @@ -1135,7 +1244,12 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': { + data: unknown; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; }; }; }; @@ -1159,7 +1273,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; }; }; }; @@ -1178,7 +1294,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AnalyticsEnvelopeDto']; + }; }; }; }; diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c81835a..e122902 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -24,6 +24,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/login: @@ -40,6 +44,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/refresh: @@ -50,6 +58,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/logout: @@ -60,6 +72,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthLogoutResponseDto' tags: - Auth security: @@ -72,6 +88,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' tags: - Auth security: @@ -89,6 +109,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' tags: - Auth security: @@ -258,7 +282,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ScreenerResultDto' + $ref: '#/components/schemas/ScreenerResponseDto' tags: - Securities /api/v1/securities/shares/{secid}: @@ -471,9 +495,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PortfolioListResponseDto' + $ref: '#/components/schemas/PortfolioListEnvelopeDto' tags: - Portfolios security: @@ -491,6 +513,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' tags: - Portfolios security: @@ -511,7 +537,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PortfolioDetailResponseDto' + $ref: '#/components/schemas/PortfolioDetailEnvelopeDto' tags: - Portfolios security: @@ -534,6 +560,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' tags: - Portfolios security: @@ -550,6 +580,18 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object + properties: + data: + nullable: true + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta tags: - Portfolios security: @@ -573,6 +615,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' tags: - Portfolios security: @@ -601,6 +647,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' tags: - Portfolios security: @@ -622,6 +672,18 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object + properties: + data: + nullable: true + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta tags: - Portfolios security: @@ -639,6 +701,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsEnvelopeDto' tags: - Portfolios security: @@ -672,6 +738,54 @@ components: required: - email - password + AuthUserDto: + type: object + properties: + id: + type: number + email: + type: string + name: + type: string + nullable: true + role: + type: string + required: + - id + - email + - name + - role + AuthTokenDataDto: + type: object + properties: + user: + $ref: '#/components/schemas/AuthUserDto' + accessToken: + type: string + required: + - user + - accessToken + AuthResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + AuthTokenResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthTokenDataDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta LoginDto: type: object properties: @@ -684,6 +798,33 @@ components: required: - email - password + LogoutDataDto: + type: object + properties: + message: + type: string + required: + - message + AuthLogoutResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/LogoutDataDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta + AuthProfileResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthUserDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta UpdateProfileDto: type: object properties: @@ -708,13 +849,16 @@ components: - share - bond price: - type: object + type: number + nullable: true example: 322.35 change: - type: object + type: number + nullable: true example: 1.15 changePercent: - type: object + type: number + nullable: true example: 0.36 volume: type: number @@ -723,28 +867,36 @@ components: type: number example: 1 capitalization: - type: object + type: number + nullable: true example: 6958336818320 yieldToMaturity: - type: object + type: number + nullable: true example: 12.71 duration: - type: object + type: number + nullable: true example: 4.5 couponValue: - type: object + type: number + nullable: true example: 40.64 couponPercent: - type: object + type: number + nullable: true example: 8.15 accruedInt: - type: object + type: number + nullable: true example: 29.48 matDate: - type: object + type: string + nullable: true example: '2027-02-03' bondType: - type: object + type: string + nullable: true example: ОФЗ-ПД required: - secid @@ -774,6 +926,38 @@ components: - page - pageSize - totalPages + ScreenerResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + ScreenerResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/ScreenerResultDto' + meta: + $ref: '#/components/schemas/ScreenerResponseMetaDto' + required: + - data + - meta + PortfolioResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache PortfolioListResponseDto: type: object properties: @@ -782,7 +966,8 @@ components: name: type: string description: - type: object + type: string + nullable: true currency: type: string default: RUB @@ -812,6 +997,18 @@ components: - positionCount - shareCount - bondCount + PortfolioListEnvelopeDto: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PortfolioListResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta CreatePortfolioDto: type: object properties: @@ -833,6 +1030,39 @@ components: - BYN required: - name + PortfolioResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + currency: + type: string + default: RUB + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - currency + - createdAt + - updatedAt + PortfolioEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta PositionWithPriceDto: type: object properties: @@ -842,7 +1072,8 @@ components: type: string example: SBER shortName: - type: object + type: string + nullable: true type: type: string example: share @@ -853,59 +1084,87 @@ components: type: number example: 10 buyPrice: - type: object + type: number + nullable: true buyDate: - type: object + type: string + nullable: true notes: - type: object + type: string + nullable: true tags: - type: object + nullable: true + type: array + items: + type: string currentPrice: - type: object + type: number + nullable: true totalCost: - type: object + type: number + nullable: true currentValue: - type: object + type: number + nullable: true weightPercent: type: number pnl: - type: object + type: number + nullable: true pnlPercent: - type: object + type: number + nullable: true dividendIncome: - type: object + type: number + nullable: true totalReturn: - type: object + type: number + nullable: true totalReturnPercent: - type: object + type: number + nullable: true change: - type: object + type: number + nullable: true changePercent: - type: object + type: number + nullable: true yieldToMaturity: - type: object + type: number + nullable: true duration: - type: object + type: number + nullable: true couponValue: - type: object + type: number + nullable: true couponPercent: - type: object + type: number + nullable: true nextCouponDate: - type: object + type: string + nullable: true matDate: - type: object + type: string + nullable: true accruedInt: - type: object + type: number + nullable: true bid: - type: object + type: number + nullable: true offer: - type: object + type: number + nullable: true couponPeriod: - type: object + type: number + nullable: true bondType: - type: object + type: string + nullable: true offerDate: - type: object + type: string + nullable: true required: - id - secid @@ -922,17 +1181,20 @@ components: totalPnl: type: number totalPnlPercent: - type: object + type: number + nullable: true totalDividends: type: number totalReturn: type: number totalReturnPercent: - type: object + type: number + nullable: true positionCount: type: number weightedYield: - type: object + type: number + nullable: true required: - totalInvested - totalValue @@ -951,7 +1213,8 @@ components: name: type: string description: - type: object + type: string + nullable: true currency: type: string default: RUB @@ -976,6 +1239,16 @@ components: - positions - totalValue - analytics + PortfolioDetailEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioDetailResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta UpdatePortfolioDto: type: object properties: @@ -1014,22 +1287,66 @@ components: type: string example: Покупка на дип tags: - type: string + type: array example: - DIVIDEND - GROWTH - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH required: - secid - quantity + PositionResponseDto: + type: object + properties: + id: + type: number + secid: + type: string + example: SBER + quantity: + type: number + example: 10 + notes: + type: string + nullable: true + tags: + nullable: true + type: array + items: + type: string + portfolioId: + type: number + createdAt: + type: string + updatedAt: + type: string + required: + - id + - secid + - quantity + - portfolioId + - createdAt + - updatedAt + PositionEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PositionResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta UpdatePositionDto: type: object properties: @@ -1046,15 +1363,39 @@ components: type: string example: Докупка tags: - type: string + type: array example: - DIVIDEND - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH + AnalyticsResponseDto: + type: object + properties: + positions: + type: array + items: + $ref: '#/components/schemas/PositionWithPriceDto' + summary: + $ref: '#/components/schemas/PortfolioSummaryDto' + required: + - positions + - summary + AnalyticsEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/AnalyticsResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta