# 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