diff --git a/.gitignore b/.gitignore index 84b2050..f54e210 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.superpowers/ .env *.log .DS_Store diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts new file mode 100644 index 0000000..135df1c --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PortfolioResponseDto } from './portfolio-response.dto'; + +export class PortfolioListResponseDto extends PortfolioResponseDto { + @ApiProperty({ description: 'Total market value of all positions' }) + totalValue!: number; + + @ApiProperty({ description: 'Total number of positions' }) + positionCount!: number; + + @ApiProperty({ description: 'Number of share positions' }) + shareCount!: number; + + @ApiProperty({ description: 'Number of bond positions' }) + bondCount!: number; +} diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 31cba5b..0de377a 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; +import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; import { PortfolioService } from './portfolio.service'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; @@ -15,6 +16,7 @@ export class PortfolioController { @Get() @ApiOperation({ summary: 'Get all portfolios for current user' }) + @ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) async findAll(@CurrentUser() user: { sub: number }) { const portfolios = await this.portfolioService.findAll(user.sub); return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts new file mode 100644 index 0000000..9e3742d --- /dev/null +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -0,0 +1,201 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { PortfolioService } from './portfolio.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +describe('PortfolioService', () => { + let service: PortfolioService; + let prisma: PrismaService; + let moexClient: MoexClientService; + let module: TestingModule; + + const mockPortfolio = (overrides: Record = {}) => ({ + id: 1, + userId: 1, + name: 'Test Portfolio', + description: 'A test portfolio', + currency: 'RUB', + targets: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + const mockPosition = (overrides: Record = {}) => ({ + id: 1, + portfolioId: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + notes: null, + tags: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + PortfolioService, + { + provide: PrismaService, + useValue: { + portfolio: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + position: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, + }, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + getSecurityDescription: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PortfolioService); + prisma = module.get(PrismaService); + moexClient = module.get(MoexClientService); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return empty array when user has no portfolios', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + const result = await service.findAll(1); + expect(result).toEqual([]); + }); + + it('should return portfolios with zero aggregates when no positions exist', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [] }) as any, + ]); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'Test Portfolio', + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + }); + }); + + it('should enrich portfolios with market data from batch MOEX call', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + const bondPosition = mockPosition({ + id: 2, + portfolioId: 1, + secid: 'SU26238RMFS5', + type: 'bond', + quantity: 5, + }); + + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, + ]); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + { + secid: 'SU26238RMFS5', + shortName: 'OFZ 26238', + price: 98.5, + faceValue: 1000, + }, + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Test Portfolio'); + expect(result[0].positionCount).toBe(2); + expect(result[0].shareCount).toBe(1); + expect(result[0].bondCount).toBe(1); + // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 + expect(result[0].totalValue).toBe(7425); + }); + + it('should propagate MOEX errors to the caller', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [mockPosition()] }) as any, + ]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down')); + + await expect(service.findAll(1)).rejects.toThrow('MOEX down'); + }); + + it('should only return portfolios belonging to the requesting user', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + + await service.findAll(2); + + expect(prisma.portfolio.findMany).toHaveBeenCalledWith({ + where: { userId: 2 }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + }); + }); + + describe('findOne', () => { + it('should throw NotFoundException for non-existent portfolio', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); + await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException for wrong user', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); + await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index fd1dcd1..c58ef7d 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -60,10 +60,55 @@ export class PortfolioService { } async findAll(userId: number) { - return this.prisma.portfolio.findMany({ + const portfolios = await this.prisma.portfolio.findMany({ where: { userId }, + include: { positions: true }, orderBy: { updatedAt: 'desc' }, }); + + const allPositions = portfolios.flatMap((p) => p.positions); + if (allPositions.length === 0) { + return portfolios.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + })); + } + + const enrichedPositions = await this.enrichPositions(allPositions); + + const posByPortfolioId = new Map(); + for (let i = 0; i < enrichedPositions.length; i++) { + const pfId = allPositions[i].portfolioId; + if (!posByPortfolioId.has(pfId)) { + posByPortfolioId.set(pfId, []); + } + posByPortfolioId.get(pfId)!.push(enrichedPositions[i]); + } + + return portfolios.map((p) => { + const positions = posByPortfolioId.get(p.id) ?? []; + const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0); + return { + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: Math.round(totalValue * 100) / 100, + positionCount: positions.length, + shareCount: positions.filter((pos) => pos.type === 'share').length, + bondCount: positions.filter((pos) => pos.type === 'bond').length, + }; + }); } async findOne(userId: number, id: number) { diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index 1d00dda..2b8859a 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -142,6 +142,10 @@ export interface Portfolio { currency: string; createdAt: string; updatedAt: string; + totalValue: number; + positionCount: number; + shareCount: number; + bondCount: number; } export interface PositionWithPrice { diff --git a/apps/frontend/src/components/portfolios/PortfolioCard.tsx b/apps/frontend/src/components/portfolios/PortfolioCard.tsx index 0dc458f..82312a2 100644 --- a/apps/frontend/src/components/portfolios/PortfolioCard.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioCard.tsx @@ -2,6 +2,15 @@ import { Link } from 'react-router-dom'; import type { Portfolio } from '../../api/responses'; export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + const chipStyle = (bg: string): React.CSSProperties => ({ + background: bg, + padding: '4px 10px', + borderRadius: 6, + fontSize: 12, + color: '#fff', + fontWeight: 500, + }); + return ( (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} > -

{portfolio.name}

+
+

{portfolio.name}

+
+
+ {portfolio.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ {portfolio.currency} +
+
+
+ {portfolio.description && ( -

+

{portfolio.description}

)} - - {portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + +
+ {portfolio.shareCount > 0 && ( + + {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')} + + )} + {portfolio.bondCount > 0 && ( + + {portfolio.bondCount}{' '} + {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')} + + )} + + {portfolio.positionCount}{' '} + {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')} + +
+ + + обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} ); } + +function pluralize(n: number, one: string, few: string, many: string): string { + if (n % 10 === 1 && n % 100 !== 11) return one; + if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few; + return many; +} diff --git a/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md b/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md new file mode 100644 index 0000000..8786623 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md @@ -0,0 +1,613 @@ +# Portfolio List Enrichment — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enrich `GET /api/v1/portfolios` with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard. + +**Architecture:** Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component. + +**Tech Stack:** NestJS, Prisma, MoexClientService (batch), React, TanStack Query + +--- + +### Task 1: Create PortfolioListResponseDto + +**Files:** +- Create: `apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts` + +- [ ] **Step 1: Create DTO file** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { PortfolioResponseDto } from './portfolio-response.dto'; + +export class PortfolioListResponseDto extends PortfolioResponseDto { + @ApiProperty({ description: 'Total market value of all positions' }) + totalValue!: number; + + @ApiProperty({ description: 'Total number of positions' }) + positionCount!: number; + + @ApiProperty({ description: 'Number of share positions' }) + shareCount!: number; + + @ApiProperty({ description: 'Number of bond positions' }) + bondCount!: number; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit -w apps/backend` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts +git commit -m "feat(backend): add PortfolioListResponseDto" +``` + +--- + +### Task 2: Write failing tests for PortfolioService.findAll enrichment + +**Files:** +- Create: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` + +- [ ] **Step 1: Create test file with failing tests** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { PortfolioService } from './portfolio.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +describe('PortfolioService', () => { + let service: PortfolioService; + let prisma: PrismaService; + let moexClient: MoexClientService; + let module: TestingModule; + + const mockPortfolio = (overrides: Record = {}) => ({ + id: 1, + userId: 1, + name: 'Test Portfolio', + description: 'A test portfolio', + currency: 'RUB', + targets: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + const mockPosition = (overrides: Record = {}) => ({ + id: 1, + portfolioId: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + notes: null, + tags: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + PortfolioService, + { + provide: PrismaService, + useValue: { + portfolio: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + position: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, + }, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + getSecurityDescription: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PortfolioService); + prisma = module.get(PrismaService); + moexClient = module.get(MoexClientService); + // CacheService is a useValue mock object + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return empty array when user has no portfolios', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + const result = await service.findAll(1); + expect(result).toEqual([]); + }); + + it('should return portfolios with zero aggregates when no positions exist', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [] }) as any, + ]); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'Test Portfolio', + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + }); + }); + + it('should enrich portfolios with market data from batch MOEX call', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + const bondPosition = mockPosition({ + id: 2, + portfolioId: 1, + secid: 'SU26238RMFS5', + type: 'bond', + quantity: 5, + }); + + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, + ]); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + { + secid: 'SU26238RMFS5', + shortName: 'OFZ 26238', + price: 98.5, + faceValue: 1000, + }, + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Test Portfolio'); + expect(result[0].positionCount).toBe(2); + expect(result[0].shareCount).toBe(1); + expect(result[0].bondCount).toBe(1); + // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 + expect(result[0].totalValue).toBe(7425); + }); + + it('should propagate MOEX errors to the caller', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [mockPosition()] }) as any, + ]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down')); + + await expect(service.findAll(1)).rejects.toThrow('MOEX down'); + }); + + it('should only return portfolios belonging to the requesting user', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + + await service.findAll(2); + + expect(prisma.portfolio.findMany).toHaveBeenCalledWith({ + where: { userId: 2 }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + }); + }); + + describe('findOne', () => { + it('should throw NotFoundException for non-existent portfolio', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); + await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException for wrong user', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); + await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: FAIL — tests assert behavior that's not yet implemented + +--- + +### Task 3: Implement backend enrichment in PortfolioService.findAll + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` — rewrite `findAll` method + +- [ ] **Step 1: Replace the findAll method** + +Current code (lines 62-67): +```typescript +async findAll(userId: number) { + return this.prisma.portfolio.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + }); +} +``` + +Replace with: +```typescript +async findAll(userId: number) { + const portfolios = await this.prisma.portfolio.findMany({ + where: { userId }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + + const allPositions = portfolios.flatMap((p) => p.positions); + if (allPositions.length === 0) { + return portfolios.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + })); + } + + const enrichedPositions = await this.enrichPositions(allPositions); + + const posByPortfolioId = new Map(); + for (let i = 0; i < enrichedPositions.length; i++) { + const pfId = allPositions[i].portfolioId; + if (!posByPortfolioId.has(pfId)) { + posByPortfolioId.set(pfId, []); + } + posByPortfolioId.get(pfId)!.push(enrichedPositions[i]); + } + + return portfolios.map((p) => { + const positions = posByPortfolioId.get(p.id) ?? []; + const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0); + return { + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: Math.round(totalValue * 100) / 100, + positionCount: positions.length, + shareCount: positions.filter((pos) => pos.type === 'share').length, + bondCount: positions.filter((pos) => pos.type === 'bond').length, + }; + }); +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/backend/src/modules/portfolio/portfolio.service.ts \ + apps/backend/src/modules/portfolio/portfolio.service.spec.ts +git commit -m "feat(backend): enrich portfolio list with MOEX batch data" +``` + +--- + +### Task 4: Update PortfolioController with new DTO + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.controller.ts` + +- [ ] **Step 1: Import PortfolioListResponseDto** + +Add import at top: +```typescript +import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; +``` + +- [ ] **Step 2: Update findAll to use new DTO in Swagger** + +Replace method with ApiResponse decorator: +```typescript +@Get() +@ApiOperation({ summary: 'Get all portfolios for current user' }) +@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) +async findAll(@CurrentUser() user: { sub: number }) { + const portfolios = await this.portfolioService.findAll(user.sub); + return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; +} +``` + +Also add the import: +```typescript +import { ApiOkResponse } from '@nestjs/swagger'; +``` + +- [ ] **Step 3: Run existing tests to verify no regressions** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/src/modules/portfolio/portfolio.controller.ts +git commit -m "feat(backend): add Swagger decorators for enriched portfolio list" +``` + +--- + +### Task 5: Update frontend types + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` + +- [ ] **Step 1: Add new fields to Portfolio interface** + +Current (lines 138-145): +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; +} +``` + +Replace with: +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; + totalValue: number; + positionCount: number; + shareCount: number; + bondCount: number; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc -b apps/frontend` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/api/responses.ts +git commit -m "feat(frontend): add enrichment fields to Portfolio type" +``` + +--- + +### Task 6: Update PortfolioCard to show enriched data + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/PortfolioCard.tsx` + +- [ ] **Step 1: Replace PortfolioCard implementation** + +Current (lines 1-39): +```typescript +import { Link } from 'react-router-dom'; +import type { Portfolio } from '../../api/responses'; + +export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + return ( + (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +

{portfolio.name}

+ {portfolio.description && ( +

+ {portfolio.description} +

+ )} + + {portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + + + ); +} +``` + +Replace with: +```typescript +import { Link } from 'react-router-dom'; +import type { Portfolio } from '../../api/responses'; + +export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + const chipStyle = (bg: string): React.CSSProperties => ({ + background: bg, + padding: '4px 10px', + borderRadius: 6, + fontSize: 12, + color: '#fff', + fontWeight: 500, + }); + + return ( + (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +
+

{portfolio.name}

+
+
+ {portfolio.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ {portfolio.currency} +
+
+
+ + {portfolio.description && ( +

+ {portfolio.description} +

+ )} + +
+ {portfolio.shareCount > 0 && ( + + {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')} + + )} + {portfolio.bondCount > 0 && ( + + {portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')} + + )} + + {portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')} + +
+ + + обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + + + ); +} + +function pluralize(n: number, one: string, few: string, many: string): string { + if (n % 10 === 1 && n % 100 !== 11) return one; + if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few; + return many; +} +``` + +- [ ] **Step 2: Verify frontend builds** + +Run: `npm run build:frontend -w apps/frontend` (or `npx tsc -b apps/frontend`) +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/components/portfolios/PortfolioCard.tsx +git commit -m "feat(frontend): display enriched data in PortfolioCard" +``` + +--- + +### Task 7: Run full test suite and verify + +- [ ] **Step 1: Run backend tests** + +Run: `npm run test:backend` +Expected: All tests pass (including new portfolio service tests) + +- [ ] **Step 2: Run frontend build** + +Run: `npm run build:frontend` +Expected: Build succeeds + +- [ ] **Step 3: Run linter** + +Run: `npm run lint` +Expected: No lint errors diff --git a/docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md b/docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md new file mode 100644 index 0000000..e124d6e --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md @@ -0,0 +1,150 @@ +# Portfolio List Enrichment + +**Date:** 2026-06-14 +**Status:** Draft + +## Problem + +`GET /api/v1/portfolios` возвращает сырые записи из БД без какой-либо обогащённой информации. На фронтенде карточка портфеля показывает только название, описание, валюту и дату обновления. Пользователь не видит общую стоимость портфеля, количество позиций и распределение по типам без перехода на страницу деталей. + +## Solution + +Обогатить `GET /api/v1/portfolios` данными из MOEX, используя тот же batch-подход, что и в `enrichPositions` для детального эндпоинта. + +### Backend + +#### Новый DTO: `PortfolioListResponseDto` + +```typescript +class PortfolioListResponseDto extends PortfolioResponseDto { + totalValue: number; + positionCount: number; + shareCount: number; + bondCount: number; +} +``` + +#### Изменение `PortfolioService.findAll(userId)` + +Текущий код: +```typescript +async findAll(userId: number) { + return this.prisma.portfolio.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + }); +} +``` + +Новый код: +```typescript +async findAll(userId: number) { + const portfolios = await this.prisma.portfolio.findMany({ + where: { userId }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + + // Собрать все unique secid из всех портфелей + const allPositions = portfolios.flatMap((p) => p.positions); + if (allPositions.length === 0) { + return portfolios.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + })); + } + + // Один batch-запрос к MOEX для всех secid разом + const enrichedPositions = await this.enrichPositions(allPositions); + const posByPortfolioId = new Map(); + for (const pos of enrichedPositions) { + const pfId = allPositions.find((ap) => ap.id === pos.id)!.portfolioId; + if (!posByPortfolioId.has(pfId)) posByPortfolioId.set(pfId, []); + posByPortfolioId.get(pfId)!.push(pos); + } + + return portfolios.map((p) => { + const positions = posByPortfolioId.get(p.id) ?? []; + const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0); + return { + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: Math.round(totalValue * 100) / 100, + positionCount: positions.length, + shareCount: positions.filter((pos) => pos.type === 'share').length, + bondCount: positions.filter((pos) => pos.type === 'bond').length, + }; + }); +} +``` + +#### Изменение `PortfolioController.findAll` + +Ответ типизируется как `PortfolioListResponseDto[]`. + +### Frontend + +#### Тип `Portfolio` в `responses.ts` — добавить поля + +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; + totalValue: number; + positionCount: number; + shareCount: number; + bondCount: number; +} +``` + +#### `PortfolioCard.tsx` — расширить + +Показывать: +1. Название (слева) + общая стоимость (справа, крупно, с валютой) +2. Описание (если есть) +3. Чипсы: `N акций`, `M облигаций`, `K позиций` (тёмный фон, белый текст) +4. Дата обновления + +#### `PortfoliosListPage.tsx` — без изменений + +### Data Flow + +``` +GET /api/v1/portfolios + → PortfolioService.findAll(userId) + → prisma.portfolio.findMany({ include: { positions: true } }) + → Collect all unique secids across all portfolios + → enrichPositions(allPositions) // ONE batch MOEX call (cached) + → fetchShareBatch(shareSecids) // batched + → fetchBondBatch(bondSecids) // batched + → Compute aggregated fields per portfolio + → Return PortfolioListResponseDto[] +``` + +Кеширование: batch-запросы к MOEX уже кешируются через `CacheService` с `marketDataTtl` (900s). При повторном запросе в течение 15 минут ответ будет из кеша. + +### Risks and Edge Cases + +| Risk | Mitigation | +|---|---| +| **Позиций нет ни в одном портфеле** | Early return без вызова MOEX | +| **MOEX недоступен** | enrichPositions уже обрабатывает `null` данные — totalValue будет 0, чипсы покажут только количество | +| **Много портфелей с сотнями позиций** | Те же batch-запросы, что и у findOne — не более 2 HTTP-вызовов | +| **Кеш прогрет от findOne** | List получит данные мгновенно | +| **Пустой portfolioId Map** | positions = [] → totalValue = 0, positionCount = 0 |