docs: consolidate SDD documentation
Some checks failed
CI / lint (pull_request) Successful in 2m10s
CI / build (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
Sergey Krylov 2026-06-15 21:04:24 +03:00
parent 117f182851
commit 739405a597
34 changed files with 1202 additions and 15734 deletions

View File

@ -6,9 +6,23 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
## Обязательный подход к разработке
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
## Git workflow
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
## Документация и SDD-артефакты
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
## Команды
@ -37,11 +51,14 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
| `PORT` | 3000 | Порт бэкенда |
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
@ -55,7 +72,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
- БД: SQLite через Prisma ORM. Prisma client используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`.
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.

View File

@ -54,7 +54,5 @@ apps/
frontend/ — React SPA with Vite
docs/ — Docusaurus documentation site
docs/
architecture/ — ADR documents and diagrams
openapi/ — OpenAPI specification
superpowers/ — Design specs and implementation plans
superpowers/specs/ — accepted SDD specifications
```

View File

@ -1,14 +1,9 @@
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', () => {
describe('checked-in OpenAPI frontend types', () => {
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',
@ -30,79 +25,33 @@ describe('checked-in OpenAPI artifacts', () => {
}
});
it('static OpenAPI YAML snapshot includes current protected domains', () => {
for (const path of requiredPaths) {
expect(openapiYaml).toContain(`${path}:`);
}
});
it('checked-in artifacts do not leak the local alternate codegen port', () => {
it('frontend generated types 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;
it('frontend generated types include typed operations for current protected domains', () => {
const requiredOperations = [
'AuthController_register',
'AuthController_login',
'AuthController_refresh',
'AuthController_logout',
'AuthController_getProfile',
'AuthController_updateProfile',
'SecuritiesController_screener',
'PortfolioController_findAll',
'PortfolioController_create',
'PortfolioController_findOne',
'PortfolioController_update',
'PortfolioController_remove',
'PortfolioController_addPosition',
'PortfolioController_updatePosition',
'PortfolioController_removePosition',
'PortfolioController_getAnalytics',
];
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();
}
});
it('portfolio delete operations document a null data envelope', () => {
for (const [method, path, status] of [
['delete', '/api/v1/portfolios/{id}', 200],
['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200],
] as const) {
expect(
openapi.paths[path][method].responses[status].content['application/json'].schema.properties
.data,
).toMatchObject({ type: 'null' });
for (const operation of requiredOperations) {
expect(frontendTypes).toContain(`operations['${operation}']`);
}
});
});

View File

@ -9,5 +9,8 @@
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI Codegen for Frontend |
| [ADR-006](ADR-006-no-cci) | Deprecated | No Custom Components Infrastructure |
| [ADR-007](ADR-007-two-level-caching) | Draft | Two-Level Caching (In-Memory + Redis) |
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication & Authorization |
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Portfolio Domain Model |
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Backend Price Computation |
Все ADR находятся в `docs/architecture/adr/`.
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -73,7 +73,7 @@ sequenceDiagram
## Architecture Decisions
All architectural decisions are documented as ADR in `docs/architecture/adr/`:
All architectural decisions are documented as ADR pages in this documentation app:
| ADR | Summary |
|---|---|
@ -85,6 +85,8 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
| [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization |
| [ADR-009](adr/ADR-009-portfolio-domain) | Portfolio domain model |
| [ADR-010](adr/ADR-010-backend-price-computation) | Backend price computation |
## Response Format

View File

@ -7,17 +7,24 @@ graph TD
AppModule --> ConfigModule
AppModule --> CacheModule
AppModule --> MoexClientModule
AppModule --> PrismaModule
AppModule --> HealthModule
AppModule --> AuthModule
AppModule --> SecuritiesModule
AppModule --> SharesModule
AppModule --> BondsModule
AppModule --> CandlesModule
AppModule --> PortfolioModule
subgraph Global_Modules["Global Modules"]
PrismaModule
CacheModule
MoexClientModule
end
AuthModule --> PrismaService["PrismaService"]
PortfolioModule --> PrismaService
PortfolioModule --> MoexClientService
SharesModule --> CacheService["CacheService"]
BondsModule --> CacheService
SecuritiesModule --> CacheService
@ -33,13 +40,24 @@ graph TD
| Module | Global | Path | Description |
|---|---|---|---|
| `PrismaModule` | Yes | `modules/prisma/` | Prisma client for SQLite |
| `CacheModule` | Yes | `modules/cache/` | In-memory cache (cache-manager) |
| `MoexClientModule` | Yes | `modules/moex-client/` | HTTP-клиент MOEX ISS |
| `HealthModule` | No | `modules/health/` | Health check endpoint |
| `AuthModule` | No | `modules/auth/` | JWT auth, refresh cookie, guards |
| `SecuritiesModule` | No | `modules/securities/` | Поиск инструментов |
| `SharesModule` | No | `modules/shares/` | Акции |
| `BondsModule` | No | `modules/bonds/` | Облигации |
| `CandlesModule` | No | `modules/candles/` | Свечи OHLCV |
| `PortfolioModule` | No | `modules/portfolio/` | Пользовательские портфели и аналитика |
### PrismaModule
Глобальный модуль доступа к SQLite через Prisma.
- `PrismaService` подключается к базе из `DATABASE_URL`
- Схема и миграции находятся в `apps/backend/prisma/`
- Используется `@prisma/client`; отдельный generated client в `src/generated/prisma/` не создаётся
### CacheModule
@ -63,6 +81,15 @@ graph TD
- `GET /api/v1/health``{ status: 'ok', timestamp, uptime }`
### AuthModule
Аутентификация и авторизация.
- JWT access token передаётся в `Authorization: Bearer <token>`
- Refresh token хранится в httpOnly cookie и в БД как bcrypt hash
- `JwtAuthGuard` и `RolesGuard` зарегистрированы глобально через `APP_GUARD`
- Открытые endpoints помечаются `@Public()`
### SecuritiesModule
Поиск по инструментам (акции и облигации).
@ -92,3 +119,12 @@ graph TD
- 2 эндпоинта: для акций и облигаций
- Интервалы: `1h` (60 min) и `24h` (daily)
- Маппинг: `1h` → MOEX interval 60, `24h` → MOEX interval 24
### PortfolioModule
Портфели пользователя и аналитика позиций.
- CRUD портфелей и позиций
- Обогащение позиций текущими ценами из MOEX
- Расчёт summary, PnL и долей портфеля
- Все endpoints защищены JWT

View File

@ -24,7 +24,7 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
### Output
- `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations`
- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации
- Live Swagger JSON на `http://localhost:3000/api/docs-json` остаётся источником OpenAPI-контракта
### Verify Artifacts
@ -34,9 +34,8 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
```
Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio
paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей,
array enum tags и typed response envelopes.
Тест проверяет, что checked-in frontend types содержат актуальные auth, screener и portfolio paths
и не содержат локальный alternate port.
### Manual Types

View File

@ -22,9 +22,8 @@ moex-vibe/
│ ├── frontend/ # React SPA
│ └── docs/ # Docusaurus documentation site
├── docs/
│ ├── architecture/ # ADR и диаграммы
│ ├── openapi/ # OpenAPI-спецификация
│ └── superpowers/ # Дизайн-спеки и планы
│ └── superpowers/
│ └── specs/ # Согласованные SDD-спецификации
├── docker/
│ ├── Dockerfile.backend
│ ├── Dockerfile.frontend
@ -39,3 +38,4 @@ moex-vibe/
- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`.
- Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`.
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
- `apps/docs` — опубликованная документация; root `docs` хранит только SDD specs.

View File

@ -18,6 +18,10 @@ const config: Config = {
locales: ['ru'],
},
markdown: {
mermaid: true,
},
presets: [
[
'classic',
@ -34,6 +38,8 @@ const config: Config = {
],
],
themes: ['@docusaurus/theme-mermaid'],
themeConfig: {
navbar: {
title: 'MoexVibe',

View File

@ -10,6 +10,7 @@
"dependencies": {
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@docusaurus/theme-mermaid": "3.7.0",
"@mdx-js/react": "^3.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"

View File

@ -1,25 +0,0 @@
# ADR-001: Backend — Single Point of Access to MOEX
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Tech Lead
## Context
Frontend должен отображать данные Московской биржи. MOEX ISS API отдаёт сырые данные со сложной структурой (вложенные таблицы, различные форматы). Прямые запросы с фронта приведут к дублированию логики нормализации, усложнят обработку ошибок и сделают систему зависимой от внешнего API.
## Decision
Backend (NestJS) является единственной точкой доступа к MOEX. Frontend никогда не обращается к MOEX напрямую.
Backend:
- Проксирует запросы к MOEX ISS
- Нормализует данные в доменные модели
- Кеширует ответы
- Обрабатывает ошибки MOEX (пустые данные, rate limit, таймауты)
- Предоставляет собственный OpenAPI-контракт для фронта
## Consequences
- Единый источник правды для трансформации данных
- Изоляция изменений MOEX API — меняется только MoexClient
- Централизованное кеширование сокращает количество запросов к MOEX
- Фронтенд остаётся тонким клиентом
- Дополнительная задержка (один hop), но нивелируется кешированием

View File

@ -1,33 +0,0 @@
# ADR-002: In-Memory Cache with Migration Path to Redis
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Tech Lead
## Context
Для MVP требуется кеширование MOEX-данных, чтобы снизить нагрузку на внешнее API и обеспечить приемлемое время ответа. На начальном этапе нет требований к горизонтальному масштабированию, и хочется избежать внешних зависимостей.
## Decision
Использовать `@nestjs/cache-manager` с MemoryStore. TTL настраивается per-endpoint через конфигурацию.
Архитектура позволяет переключиться на Redis заменой импорта провайдера:
```typescript
// Текущая реализация
CacheModule.register({ store: 'memory', ttl: 900 })
// Миграция на Redis (меняется только registration)
CacheModule.registerAsync({
useFactory: () => ({
store: redisStore,
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
}),
})
```
## Consequences
- Нет внешних зависимостей для MVP
- Кеш сбрасывается при рестарте сервера (приемлемо для read-only приложения)
- Чистый путь миграции на Redis
- Единый API для cache (cache-manager abstraction)

View File

@ -1,21 +0,0 @@
# ADR-003: Rate Limiting Strategy for MOEX Client
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Tech Lead
## Context
MOEX ISS не документирует жёсткие лимиты на количество запросов, но массовые запросы могут привести к блокировке или ухудшению качества обслуживания. Backend является единственным клиентом MOEX и должен контролировать исходящий трафик.
## Decision
Внедрить два механизма в MoexClient:
1. **Request Queue (p-queue)**: конфигурируемый лимит запросов в секунду (default: 10 req/s). Запросы сверх лимита ставятся в очередь и выполняются по расписанию.
2. **Circuit Breaker (`@nestjs/axios` + interceptor)**: при 5+ последовательных ошибках (5xx, timeout, network error) клиент перестаёт отправлять запросы к MOEX на 30 секунд. После таймаута — пробный запрос для восстановления.
## Consequences
- Плавная нагрузка на MOEX, без пиков
- Автоматическое восстановление после сбоев MOEX
- Graceful degradation: при отключённом circuit breaker возвращаются кешированные данные
- Параметр конфигурации `MOEX_RATE_LIMIT` (int, req/s)

View File

@ -1,29 +0,0 @@
# ADR-004: Feature Modules by Domain
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Tech Lead
## Context
NestJS рекомендует модульную архитектуру. Требования указывают на архитектуру по feature modules. Модули должны иметь чёткие границы и быть тестируемыми изолированно.
## Decision
Каждый бизнес-домен — отдельный NestJS feature module:
| Module | Responsibility |
|--------|---------------|
| `MoexClientModule` | HTTP-клиент к MOEX ISS, rate limiting, circuit breaker |
| `CacheModule` | Абстракция кеширования |
| `SecuritiesModule` | Поиск по инструментам |
| `SharesModule` | Спецификация, marketdata, дивиденды |
| `BondsModule` | Спецификация, marketdata |
| `CandlesModule` | OHLCV свечи (общий для shares+bonds) |
| `HealthModule` | Healthcheck endpoint |
Каждый module exports свой сервис, control imports через `@Module({ imports: [...] })`.
## Consequences
- Чёткие границы, изолированное тестирование
- Возможность вынести модуль в отдельный микросервис
- Понятная навигация по коду
- Нет циклических зависимостей (MoexClient — единственный downstream)

View File

@ -1,40 +0,0 @@
# ADR-005: OpenAPI Codegen with openapi-typescript
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Tech Lead
## Context
Frontend должен потреблять API бэкенда. Ручное написание клиентов и DTO приводит к рассинхронизации с бэкендом и ошибкам типизации.
## Decision
Использовать `openapi-typescript` + `openapi-fetch` для генерации:
- TypeScript типов (DTO, request/response schemas)
- Fetcher клиента (типобезопасные вызовы)
Процесс:
1. Backend генерирует OpenAPI spec через `@nestjs/swagger`
2. `openapi-typescript` на фронте генерирует типы
3. `openapi-fetch` создаёт типобезопасный HTTP-клиент
4. Разработчик пишет TanStack Query hooks вручную поверх сгенерированного клиента
```typescript
// Пример: типобезопасный хук
import { getSharesSecid } from '@/api/client';
import type { components } from '@/api/types';
export function useStock(secid: string) {
return useQuery({
queryKey: ['stock', secid],
queryFn: () => getSharesSecid(secid),
staleTime: 900_000, // 15 min
});
}
```
## Consequences
- Полная типобезопасность на стыке frontend/backend
- Автоматическая синхронизация с API-контрактом
- TanStack Query hooks пишутся вручную — полный контроль staleTime/caching
- Добавляется шаг в CI: codegen при изменении OpenAPI spec

View File

@ -1,20 +0,0 @@
# ADR-006: CCI (Financial Reporting) Moved Out of MVP
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect, Product
## Context
MOEX предоставляет корпоративную информацию (CCI) — финансовую отчётность по МСФО/РСБУ. Данные включают отчёты о прибылях/убытках, балансовые отчёты, мультипликаторы. Однако:
- CCI API имеет собственную сложную структуру (виды отчётности, периоды, индикаторы)
- Данные требуют дополнительной нормализации и расчёта метрик
- Для MVP пользователи хотят базовую информацию (цена, купон, график)
## Decision
Не включать CCI в MVP. Roadmap на post-MVP.
## Consequences
- Меньший объём работы в MVP
- API не привязывается к CCI-схемам (будет отдельный модуль)
- Пользователи не увидят мультипликаторы (P/E, EV/EBITDA) в первой версии

View File

@ -1,27 +0,0 @@
# ADR-007: Two-Level Caching (Backend + Frontend)
**Status:** Accepted
**Date:** 2026-06-13
**Deciders:** Architect
## Context
Данные MOEX имеют задержку 15 минут. Кеширование на одном уровне (только бэкенд или только фронтенд) неоптимально:
- Только бэкенд: каждый пользователь создаёт запрос к серверу
- Только фронтенд: нет централизованного кеша, не защищает MOEX от повторных запросов
## Decision
Внедрить два уровня кеширования:
1. **Backend (in-memory cache-manager)**: централизованное кеширование ответов от MOEX. Предотвращает повторные запросы к MOEX от разных пользователей.
2. **Frontend (TanStack Query staleTime)**: предотвращает повторные запросы к бэкенду при навигации или монтировании компонентов.
TTL согласованы (см. Caching Strategy).
Cache-Control заголовки в HTTP-ответах для промежуточных proxy/CDN (опционально).
## Consequences
- Избыточность intentional: resilience при отказе одного уровня
- TanStack Query staleTime = backend TTL (нет лишних запросов)
- При рестарте бэкенда фронт всё ещё имеет данные в memory cache
- Небольшое увеличение memory на фронте (приемлемо для SPA)

View File

@ -1,24 +0,0 @@
# Architecture Overview
```mermaid
sequenceDiagram
participant User
participant Frontend as React SPA
participant Backend as NestJS API
participant Cache as In-Memory Cache
participant MOEX as MOEX ISS
User->>Frontend: Search / View instrument
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
Backend->>Cache: getOrFetch('search:sber')
alt Cache miss
Cache->>Backend: null
Backend->>MOEX: GET /iss/securities?q=SBER
MOEX-->>Backend: raw data
Backend->>Cache: set('search:sber', normalized, TTL=3600)
else Cache hit
Cache-->>Backend: cached data
end
Backend-->>Frontend: normalized response
Frontend-->>User: rendered UI
```

File diff suppressed because it is too large Load Diff

View File

@ -1,193 +0,0 @@
Ты выступаешь как Senior Solution Architect, Tech Lead и Product Analyst.
Нужно спроектировать MVP приложения для анализа инвестиций на Московской бирже (MOEX).
Перед составлением спецификации и плана разработки ты ОБЯЗАН выявить все недостающие требования и задать уточняющие вопросы. Не переходи к проектированию, пока все критические вопросы не будут закрыты.
## Источники данных
Использовать только официальные API и документацию MOEX:
* https://www.moex.com/a2193
* https://www.moex.com/a7939
* https://iss.moex.com/iss/reference/
Перед проектированием изучи доступные методы API и предложи оптимальную модель интеграции.
---
# Цель MVP
Разработать веб-приложение для анализа ценных бумаг Московской биржи.
## MVP должен включать
### Главная страница
* глобальный поиск по инструментам
* поиск акций
* поиск облигаций
* отображение результатов поиска
* переход на карточку инструмента
### Страница акции
Отображение:
* тикера
* названия компании
* текущей цены
* капитализации
* дивидендной информации
* доходности
* основных финансовых показателей (если доступны через MOEX)
* исторических данных
* графика цены
### Страница облигации
Отображение:
* ISIN
* тикера
* эмитента
* номинала
* купона
* даты погашения
* текущей цены
* доходности к погашению
* накопленного купонного дохода
* графика цены
* прочих доступных параметров
---
# Технологический стек
## Frontend
* React
* TypeScript
* Vite
* TanStack Query
* React Router
* OpenAPI Code Generation
* максимальная типизация
* SSR не требуется
## Backend
* NestJS
* TypeScript
* OpenAPI (Swagger)
* архитектура по feature modules
* DTO validation
* централизованная обработка ошибок
* structured logging
* request/response logging middleware
* healthcheck endpoint
* configuration module
## Документация
Использовать Docusaurus.
Документация должна включать:
* архитектурные решения (ADR)
* sequence diagrams
* component diagrams
* deployment diagrams
* API documentation
* OpenAPI схемы
* описание бизнес-процессов
* onboarding разработчиков
---
# Подход к разработке
Использовать:
* Superpowers
* OpenSpec
Разработка должна начинаться со спецификации.
Сначала сформировать:
1. Product Requirements Document (PRD)
2. Domain Model
3. Architecture Decision Records (ADR)
4. OpenAPI Contract
5. Frontend Architecture
6. Backend Architecture
7. План реализации по этапам
---
# Требования к API
Backend является единственной точкой доступа к MOEX.
Frontend не должен обращаться к MOEX напрямую.
Backend должен:
* агрегировать данные MOEX
* кешировать ответы
* нормализовать модели данных
* предоставлять собственный OpenAPI контракт
Необходимо предложить стратегию:
* кеширования
* rate limiting
* обработки ошибок MOEX
* обновления данных
---
# Требования к Frontend
Использовать OpenAPI codegen для генерации:
* API clients
* DTO
* React Query hooks (если возможно)
Не писать API-клиенты вручную без необходимости.
Предложить оптимальную структуру проекта.
---
# UX/UI
Использовать современные практики frontend разработки.
При проектировании интерфейсов:
* использовать MCP инструменты для анализа и генерации дизайна
* использовать frontend design skills
* подготовить описание экранов
* подготовить user flow
* подготовить wireframes в текстовом виде
---
# Ожидаемый результат
После уточнения требований сформируй:
1. список вопросов
2. PRD
3. OpenSpec спецификацию
4. архитектуру системы
5. структуру репозитория
6. OpenAPI проект
7. план реализации по спринтам
8. список рисков
9. roadmap развития после MVP
Не сокращай ответы. Действуй как архитектор уровня Staff+/Principal Engineer.

View File

@ -1,65 +0,0 @@
# ADR: Portfolio Enricher Optimization
**Date:** 2026-06-14
**Status:** Implemented
**Deciders:** AI Agent + Human
## Context
`GET /api/v1/portfolios/1` выполнялся ~29 секунд для портфеля с 104 позициями.
Причина: per-position enrichment генерировал 298 последовательных HTTP-запросов к MOEX ISS через rate limiter (10 req/s).
## Decision
Три оптимизации, реализованные одновременно:
### 1. Merge bond data calls
`getBondData` и `getBondMarketData` вызывали **один и тот же** MOEX endpoint
(`/engines/stock/markets/bonds/securities/{secid}`), но парсили разные таблицы ответа.
Новый метод `getBondPositionDataBatch` делает один запрос на все облигации и парсит обе таблицы.
**Profit:** 180 → 90 запросов для bonds
### 2. Remove redundant `getSecurityDescription`
Каждая позиция делала отдельный запрос для shortName. Но shortName уже доступен:
- в `securities` таблице ответа `getShareMarketData`
- в `getBondData` / `getBondPositionDataBatch`
Удалили вызов `getSecurityDescription` из `enrichPositions`.
**Profit:** 104 → 0 запросов
### 3. Batch requests by market
Вместо N индивидуальных запросов — группируем secid по типу и делаем 2 batch-запроса:
- `GET /engines/stock/markets/shares/securities.json?securities=SBER,VTBR,...`
- `GET /engines/stock/markets/bonds/securities.json?securities=RU000...,SU262...`
Новые методы: `getShareMarketDataBatch`, `getBondPositionDataBatch`.
**Profit:** 104 → 2 запроса
## Results
| Metric | Before | After | Reduction |
|---|---|---|---|
| API calls to MOEX | 298 | 2 | **99.3%** |
| Estimated latency (cache cold) | ~29.8s | ~0.3s | **99%** |
| Code in PortfolioService | ~150 lines | ~90 lines | **40%** |
## Consequences
- **Cache key format changed**: from `marketdata:portfolio:{secid}` / `bonddata:portfolio:{secid}` / `security:portfolio-name:{secid}` to `batchdata:shares:{sortedSecids}` / `batchdata:bonds:{sortedSecids}`. Old cache entries will naturally expire via TTL.
- **Cache granularity**: batch results are cached as a unit. If portfolio positions change, the cache key changes (because sorted secids change), triggering a fresh fetch.
- **Backward compatibility**: `getShareMarketData(secid)` and `getBondData(secid)` + `getBondMarketData(secid)` are preserved for other consumers.
## Files Changed
| File | Change |
|---|---|
| `moex-client.types.ts` | Added `shortName` to `MoexShareMarketData`, added `MoexBondPositionData` |
| `moex-client.service.ts` | Added `getShareMarketDataBatch`, `getBondPositionDataBatch`, added `shortName` to `getShareMarketData` |
| `portfolio.service.ts` | Rewrote `enrichPositions` to batch, removed redundant `getSecurityDescription` calls, removed old per-position enrichment methods |

File diff suppressed because it is too large Load Diff

View File

@ -1,110 +0,0 @@
# CI/CD 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:** Add Gitea Actions CI pipeline with lint, test, and build for the MoexVibe monorepo.
**Architecture:** Single `.gitea/workflows/ci.yml` file with three parallel jobs (lint, test, build) triggered on push/PR to main. One script addition to root `package.json` for format checking.
**Tech Stack:** Gitea Actions (GitHub Actions-compatible YAML), Node.js 20, npm workspaces
---
### Task 1: Add `format:check` script to root package.json
**Files:**
- Modify: `package.json` (root)
- [ ] **Step 1: Read root package.json**
- [ ] **Step 2: Add format:check script**
Edit `package.json`: add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` to the `scripts` section, after `format`.
- [ ] **Step 3: Verify the script runs**
Run: `npm run format:check`
Expected: exits 0 (all files already formatted) or lists formatting errors
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "ci: add format:check script for CI pipeline"
```
---
### Task 2: Create Gitea Actions workflow
**Files:**
- Create: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Create workflow directory**
Run: `mkdir -p .gitea/workflows`
- [ ] **Step 2: Create ci.yml with full pipeline**
Create `.gitea/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: 20
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npx prettier --check "**/*.{ts,tsx}"
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run test:backend
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run build:backend
- run: npm run build:frontend
```
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/ci.yml
git commit -m "ci: add Gitea Actions pipeline with lint, test, build"
```
---
### Verification
После пуша в `main` (или создания PR) проверить на https://git.ksv741.keenetic.pro/moex/moex-vibe/actions что pipeline запустился и все 3 job'а зелёные.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,261 +0,0 @@
# Диаграмма распределения портфеля — План реализации
> **Для агентов:** Требуется навык `superpowers:subagent-driven-development` или `superpowers:executing-plans`. Шаги используют `- [ ]`.
**Цель:** Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями.
**Архитектура:** Всё на клиенте. Бэкенд уже возвращает `positions` с `currentValue` и `type`. Новый компонент `AllocationChart` агрегирует данные и рисует SVG. `PortfolioSummary` включает его.
**Технологии:** React 18, SVG (без библиотек).
---
### Задача 1: Создать AllocationChart
**Файлы:**
- Создать: `apps/frontend/src/components/portfolios/AllocationChart.tsx`
- [ ] **Шаг 1: Создать AllocationChart.tsx**
```tsx
import type { PositionWithPrice } from '../../api/responses';
interface AllocationChartProps {
positions: PositionWithPrice[];
totalValue: number;
}
interface SectorData {
type: 'share' | 'bond';
label: string;
value: number;
count: number;
color: string;
}
const SECTOR_COLORS = {
share: 'var(--color-primary, #1976d2)',
bond: '#f57c00',
} as const;
const SECTOR_LABELS = {
share: 'Акции',
bond: 'Облигации',
} as const;
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
const sectors: SectorData[] = [
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
];
for (const p of positions) {
const sector = sectors.find((s) => s.type === p.type);
if (sector) {
sector.value += p.currentValue ?? 0;
sector.count += 1;
}
}
return sectors;
}
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
const sectors = computeSectors(positions);
const nonZero = sectors.filter((s) => s.value > 0);
const hasData = nonZero.length > 0;
const cx = 60;
const cy = 60;
const r = 44;
const strokeWidth = 10;
const circumference = 2 * Math.PI * r;
const viewBoxSize = 120;
function renderArcs() {
if (!hasData) {
return (
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke="#e0e0e0"
strokeWidth={strokeWidth}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
}
if (nonZero.length === 1) {
const sector = nonZero[0];
return (
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={sector.color}
strokeWidth={strokeWidth}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
}
return sectors.map((sector, i) => {
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
const dashLen = ratio * circumference;
const gapLen = circumference - dashLen;
let rotation = -90;
for (let j = 0; j < i; j++) {
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
rotation += prevRatio * 360;
}
return (
<circle
key={sector.type}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={sector.color}
strokeWidth={strokeWidth}
strokeDasharray={`${dashLen} ${gapLen}`}
transform={`rotate(${rotation} ${cx} ${cy})`}
style={{ transition: 'stroke-dasharray 0.3s ease' }}
/>
);
});
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<svg
width={90}
height={90}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
style={{ flexShrink: 0 }}
>
{renderArcs()}
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
style={{
fontSize: hasData && positions.length > 0 ? 14 : 10,
fontWeight: 700,
fill: 'var(--color-text)',
}}
>
{positions.length === 0
? 'Нет позиций'
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
</text>
</svg>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
{sectors.map((s) => {
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
return (
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 2,
background: s.color,
flexShrink: 0,
}}
/>
<span>
{s.label}: {s.count} / {ratio.toFixed(1)}%
</span>
</div>
);
})}
</div>
</div>
);
}
```
- [ ] **Шаг 2: Проверить сборку**
Run: `npm run build:frontend`
Expected: без ошибок
---
### Задача 2: Интегрировать в PortfolioSummary
**Файлы:**
- Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
- [ ] **Шаг 1: Обновить PortfolioSummary**
```tsx
import { AllocationChart } from './AllocationChart';
import type { PortfolioDetail } from '../../api/responses';
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
return (
<div
style={{
display: 'flex',
gap: 32,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
}}
>
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Общая стоимость
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>
{portfolio.totalValue.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
<span
style={{
fontSize: 14,
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginLeft: 4,
}}
>
{portfolio.currency}
</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Позиций
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
</div>
</div>
);
}
```
- [ ] **Шаг 2: Проверить сборку**
Run: `npm run build:frontend`
Expected: без ошибок
- [ ] **Шаг 3: Проверить линтер**
Run: `npm run lint`
Expected: без ошибок
- [ ] **Шаг 4: Проверить форматирование**
Run: `npm run format`
Expected: без изменений

File diff suppressed because it is too large Load Diff

View File

@ -1,365 +0,0 @@
# Portfolio Enricher Optimization — 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 (`- [ ]`) for tracking.
**Goal:** Reduce portfolio enrichment from 298 MOEX API calls (~30s) to 2 batch calls (~0.3s) by merging redundant bond data calls, eliminating extra security descriptions, and batching by market.
**Architecture:** 3-phase: (1) type changes, (2) new batch methods on MoexClientService, (3) rewrite PortfolioService.enrichPositions to use batch + remove redundant calls.
**Tech Stack:** NestJS, TypeScript, MOEX ISS API, PQueue
---
### Task 1: Add types — `shortName` on share market data + `MoexBondPositionData` combined type
**Files:**
- Modify: `apps/backend/src/modules/moex-client/moex-client.types.ts`
- [ ] **Step 1: Extend `MoexShareMarketData` with `shortName`**
Add `shortName: string;` field — it's already returned by MOEX in the `securities` table of the share endpoint, but was never extracted.
- [ ] **Step 2: Add `MoexBondPositionData` combined type**
```typescript
export interface MoexBondPositionData {
secid: string;
boardid: string;
shortName: string;
price: number | null;
yieldToMaturity: number | null;
duration: number | null;
couponValue: number | null;
couponPercent: number | null;
nextCouponDate: string | null;
matDate: string | null;
accruedInt: number | null;
faceValue: number;
bid: number | null;
offer: number | null;
couponPeriod: number | null;
bondType: string | null;
offerDate: string | null;
}
```
This replaces the need for both `MoexBondData` + `MoexBondMarketData` — combined from a single endpoint response.
---
### Task 2: Add batch methods to MoexClientService
**Files:**
- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts`
- [ ] **Step 1: Add `getShareMarketDataBatch` method**
```typescript
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
if (secids.length === 0) return [];
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
{ securities: secids.join(','), boards: boardId },
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
return secids.map((secid) => {
const sec = securities.find((r) => r.SECID === secid && r.BOARDID === boardId)
?? securities.find((r) => r.SECID === secid);
const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId)
?? marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec?.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
});
}
```
Key: uses existing `request()` method (rate-limited via PQueue). The `securities` param accepts comma-separated secids.
- [ ] **Step 2: Add `getBondPositionDataBatch` method**
```typescript
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
if (secids.length === 0) return [];
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
{ securities: secids.join(','), boards: boardId },
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
return secids.map((secid) => {
const bond =
securities.find((r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null) ||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
securities.find((r) => r.SECID === secid);
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
marketdata.find((r) => r.LAST != null) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent: bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
matDate: (bond?.MATDATE as string) || null,
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
bondType: (bond?.BONDTYPE as string) || null,
offerDate: (bond?.OFFERDATE as string) || null,
};
});
}
```
This replaces `getBondData` + `getBondMarketData` with a single batch call that parses both tables.
- [ ] **Step 3: Update `getShareMarketData` to also extract `shortName`**
In the single-security `getShareMarketData`, find the securities row and extract shortName:
```typescript
const share = rows.find((r) => r.BOARDID === boardId);
return {
secid,
boardid: boardId,
shortName: (share?.SHORTNAME as string) || '', // NEW
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
// ... rest unchanged
};
```
- [ ] **Step 4: Run existing tests**
```bash
npx vitest run -w apps/backend
```
Expected: existing tests pass (no regressions).
---
### Task 3: Rewrite `enrichPositions` in PortfolioService
**Files:**
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts`
- [ ] **Step 1: Rewrite `enrichPositions` to use batch + eliminate redundant calls**
Strategy:
1. Group positions by type (share/bond)
2. For shares: 1 `getShareMarketDataBatch` call → map by secid
3. For bonds: 1 `getBondPositionDataBatch` call → map by secid
4. Build enriched positions from maps (no more individual API calls)
5. shortName comes from market data response (no more `getSecurityDescription`)
```typescript
private async enrichPositions(
positions: {
id: number; portfolioId: number; secid: string;
type: string; quantity: number; notes: string | null; tags: string | null;
}[],
portfolioId: number,
): Promise<EnrichedPosition[]> {
const sharePositions = positions.filter((p) => p.type === 'share');
const bondPositions = positions.filter((p) => p.type === 'bond');
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids, portfolioId),
]);
const enriched: EnrichedPosition[] = [];
for (const pos of positions) {
const base = {
id: pos.id, secid: pos.secid,
shortName: null as string | null,
type: pos.type, quantity: pos.quantity,
notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null,
weightPercent: 0, currentPrice: null as number | null,
currentValue: null as number | null,
};
if (pos.type === 'bond') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} else {
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
}
}
return enriched;
}
private async fetchShareBatch(
secids: string[], portfolioId: number,
): Promise<Map<string, MoexShareMarketData>> {
if (secids.length === 0) return new Map();
const cacheKey = secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata', ['shares', cacheKey],
() => this.moexClient.getShareMarketDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
}
private async fetchBondBatch(
secids: string[], portfolioId: number,
): Promise<Map<string, MoexBondPositionData>> {
if (secids.length === 0) return new Map();
const cacheKey = secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata', ['bonds', cacheKey],
() => this.moexClient.getBondPositionDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
}
```
- [ ] **Step 2: Add `buildSharePosition` method**
```typescript
private buildSharePosition(
pos: { id: number; secid: string; quantity: number },
base: EnrichedPosition,
data: MoexShareMarketData | undefined,
): EnrichedPosition {
if (!data) return { ...base, currentPrice: null, currentValue: null };
return {
...base,
shortName: data.shortName,
currentPrice: data.last,
change: data.lastChange,
changePercent: data.lastChangePrcnt,
currentValue: data.last !== null ? data.last * pos.quantity : null,
};
}
```
- [ ] **Step 3: Add `buildBondPosition` method**
```typescript
private buildBondPosition(
pos: { id: number; secid: string; quantity: number },
base: EnrichedPosition,
data: MoexBondPositionData | undefined,
): EnrichedPosition {
if (!data) return { ...base, currentPrice: null, currentValue: null };
const currentValue =
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
return {
...base,
shortName: data.shortName,
currentPrice: data.price,
yieldToMaturity: data.yieldToMaturity,
duration: data.duration,
couponValue: data.couponValue,
couponPercent: data.couponPercent,
nextCouponDate: data.nextCouponDate,
matDate: data.matDate,
accruedInt: data.accruedInt,
bid: data.bid,
offer: data.offer,
couponPeriod: data.couponPeriod,
bondType: data.bondType,
offerDate: data.offerDate,
currentValue,
};
}
```
- [ ] **Step 4: Update `findOne` to pass `portfolio.id` to `enrichPositions`**
```typescript
const positionsWithPrices = await this.enrichPositions(portfolio.positions, portfolio.id);
```
- [ ] **Step 5: Clean up removed methods**
Remove old private methods: `enrichSharePosition`, `enrichBondPosition` (replaced by `buildSharePosition`, `buildBondPosition`).
- [ ] **Step 6: Remove unused import `CacheService` if it becomes unused**
Actually `CacheService` is still used via `fetchShareBatch`/`fetchBondBatch`. Keep it.
- [ ] **Step 7: Run tests**
```bash
npx vitest run -w apps/backend
```
Expected: all tests pass.
---
### Task 4: Verify and lint
- [ ] **Step 1: TypeScript check**
```bash
npx tsc --noEmit -w apps/backend
```
- [ ] **Step 2: Lint**
```bash
npm run lint 2>/dev/null || echo "Lint check complete"
```
- [ ] **Step 3: Format**
```bash
npm run format
```
---
### Task 5: Document performance gain
- [ ] **Step 1: Write ADR or performance note in docs**
Add to `docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md` documenting:
- Problem: 298 API calls → 29s
- Changes made: merged bond calls, removed redundant securityDescription, batch by market
- Result: 2 API calls → ~0.3s (97% reduction)

View File

@ -1,613 +0,0 @@
# 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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService);
moexClient = module.get<MoexClientService>(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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
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<number, (typeof enrichedPositions)[number][]>();
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 (
<Link
to={`/portfolios/${portfolio.id}`}
style={{
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
textDecoration: 'none',
color: 'inherit',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
{portfolio.description && (
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{portfolio.description}
</p>
)}
<span
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
marginTop: 8,
display: 'inline-block',
}}
>
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
</span>
</Link>
);
}
```
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 (
<Link
to={`/portfolios/${portfolio.id}`}
style={{
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
textDecoration: 'none',
color: 'inherit',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
{portfolio.totalValue.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
{portfolio.currency}
</div>
</div>
</div>
{portfolio.description && (
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{portfolio.description}
</p>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{portfolio.shareCount > 0 && (
<span style={chipStyle('#1b5e20')}>
{portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
</span>
)}
{portfolio.bondCount > 0 && (
<span style={chipStyle('#0d47a1')}>
{portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
</span>
)}
<span style={chipStyle('#424242')}>
{portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
</span>
</Link>
);
}
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

File diff suppressed because it is too large Load Diff

View File

@ -1,187 +0,0 @@
# Pre-commit Checks 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:** Добавить pre-commit хуки (ESLint + Prettier) для backend и frontend, блокирующие коммит при ошибках.
**Architecture:** Husky + lint-staged в корне монорепозитория. ESLint 8 (совместим с существующим backend) для обеих workspace. Frontend получает свой `.eslintrc.cjs` с React-правилами.
**Tech Stack:** Husky 9, lint-staged 15, ESLint 8, Prettier 3
---
### Task 1: ESLint для фронтенда
**Файлы:**
- Создать: `apps/frontend/.eslintrc.cjs`
- Изменить: `apps/frontend/package.json` (scripts + devDependencies)
- [ ] **Step 1: Добавить devDependencies в `apps/frontend/package.json`**
В секцию `devDependencies` добавить:
```json
"eslint": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0"
```
- [ ] **Step 2: Добавить скрипт lint в `apps/frontend/package.json`**
```json
"lint": "eslint \"src/**/*.{ts,tsx}\""
```
- [ ] **Step 3: Создать `apps/frontend/.eslintrc.cjs`**
```js
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
root: true,
env: {
browser: true,
es2020: true,
},
settings: {
react: { version: 'detect' },
},
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'react/react-in-jsx-scope': 'off',
},
};
```
- [ ] **Step 4: Проверить, что ESLint работает на фронтенде**
Run:
```bash
npm run lint -w apps/frontend
```
Expected: ESLint проверяет все `.ts,.tsx` файлы в `apps/frontend/src/`. Если есть ошибки — мы их фиксим. Если ошибок нет — чистый выход.
- [ ] **Step 5: Commit**
```bash
git add apps/frontend/package.json apps/frontend/.eslintrc.cjs
git commit -m "feat: add ESLint config for frontend"
```
---
### Task 2: Husky + lint-staged
**Файлы:**
- Изменить: `package.json` (корень) — devDependencies + lint-staged config
- Создать: `.husky/pre-commit`
- Создать: `.husky/_/` (содержимое от `husky init`)
- [ ] **Step 1: Установить husky и lint-staged в корень**
Run:
```bash
npm install --save-dev husky lint-staged
```
- [ ] **Step 2: Инициализировать Husky**
Run:
```bash
npx husky init
```
Это создаст `.husky/` директорию с `pre-commit` хуком.
- [ ] **Step 3: Добавить lint-staged config в корневой `package.json`**
В корневой `package.json` добавить (после `devDependencies`):
```json
"lint-staged": {
"apps/backend/src/**/*.ts": ["eslint --max-warnings=0"],
"apps/backend/test/**/*.ts": ["eslint --max-warnings=0"],
"apps/frontend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"],
"*.{ts,tsx}": ["prettier --check"]
}
```
- [ ] **Step 4: Настроить `.husky/pre-commit`**
Проверить содержимое `.husky/pre-commit`:
```bash
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
```
Если `husky init` уже создал подходящий файл, оставить как есть. Убедиться, что вызов `npx lint-staged` присутствует.
- [ ] **Step 5: Commit**
```bash
git add package.json .husky/
git commit -m "feat: add Husky pre-commit hook with lint-staged"
```
---
### Task 3: Обновить root lint script
**Файлы:**
- Изменить: `package.json` (корень) — секция scripts
- [ ] **Step 1: Обновить `lint` скрипт в корневом `package.json`**
Найти строку:
```json
"lint": "npm run lint -w apps/backend",
```
Заменить на:
```json
"lint": "npm run lint -w apps/backend && npm run lint -w apps/frontend",
```
- [ ] **Step 2: Проверить, что корневой lint работает**
Run:
```bash
npm run lint
```
Expected: ESLint проходит по backend и frontend, возвращает 0 при отсутствии ошибок.
- [ ] **Step 3: Проверить chain целиком (опционально)**
Протестировать pre-commit hook:
```bash
git add . && git commit -m "test pre-commit hook"
```
Должен выполнить lint-staged, проверить ESLint + Prettier на staged файлах.
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore: update root lint script to cover both workspaces"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1102
package-lock.json generated

File diff suppressed because it is too large Load Diff