From dea0676169d385d8f13874c9ff9f30c6d40db147 Mon Sep 17 00:00:00 2001 From: vigdorov Date: Thu, 15 Jan 2026 01:59:16 +0300 Subject: [PATCH] add ai functions --- ARCHITECTURE.md | 955 ++++++++++++- CONTEXT.md | 90 +- DEVELOPMENT.md | 68 +- REQUIREMENTS.md | 148 +- ROADMAP.md | 282 +++- backend/src/ai/ai.controller.ts | 41 + backend/src/ai/ai.module.ts | 16 + backend/src/ai/ai.service.ts | 398 ++++++ backend/src/ai/dto/estimate-idea.dto.ts | 6 + .../src/ai/dto/generate-specification.dto.ts | 6 + backend/src/ai/dto/index.ts | 2 + backend/src/app.module.ts | 2 + backend/src/ideas/dto/update-idea.dto.ts | 6 +- backend/src/ideas/entities/idea.entity.ts | 20 + .../entities/specification-history.entity.ts | 31 + backend/src/ideas/ideas.module.ts | 5 +- .../1736899500000-AddAiEstimateFields.ts | 25 + .../1736942400000-AddSpecificationField.ts | 21 + .../1736943000000-AddSpecificationHistory.ts | 29 + frontend/package.json | 1 + .../AiEstimateModal/AiEstimateModal.tsx | 204 +++ .../src/components/AiEstimateModal/index.ts | 1 + .../src/components/IdeasTable/IdeasTable.tsx | 165 ++- .../src/components/IdeasTable/columns.tsx | 133 +- .../SpecificationModal/SpecificationModal.tsx | 464 +++++++ .../components/SpecificationModal/index.ts | 1 + frontend/src/hooks/useAi.ts | 63 + frontend/src/services/ai.ts | 38 + frontend/src/types/idea.ts | 35 + k8s/backend-deployment.yaml | 7 + package-lock.json | 1216 ++++++++++++++++- tests/e2e/phase3.spec.ts | 463 +++++++ tests/playwright/.auth/user.json | 12 +- 33 files changed, 4850 insertions(+), 104 deletions(-) create mode 100644 backend/src/ai/ai.controller.ts create mode 100644 backend/src/ai/ai.module.ts create mode 100644 backend/src/ai/ai.service.ts create mode 100644 backend/src/ai/dto/estimate-idea.dto.ts create mode 100644 backend/src/ai/dto/generate-specification.dto.ts create mode 100644 backend/src/ai/dto/index.ts create mode 100644 backend/src/ideas/entities/specification-history.entity.ts create mode 100644 backend/src/migrations/1736899500000-AddAiEstimateFields.ts create mode 100644 backend/src/migrations/1736942400000-AddSpecificationField.ts create mode 100644 backend/src/migrations/1736943000000-AddSpecificationHistory.ts create mode 100644 frontend/src/components/AiEstimateModal/AiEstimateModal.tsx create mode 100644 frontend/src/components/AiEstimateModal/index.ts create mode 100644 frontend/src/components/SpecificationModal/SpecificationModal.tsx create mode 100644 frontend/src/components/SpecificationModal/index.ts create mode 100644 frontend/src/hooks/useAi.ts create mode 100644 frontend/src/services/ai.ts create mode 100644 tests/e2e/phase3.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 771309a..60350d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,31 +34,272 @@ flowchart TB ```mermaid flowchart TB User["👤 Пользователь
Член команды разработки"] + Admin["👤 Администратор
email из K8s Secret"] KC["🔐 Keycloak
auth.vigdorov.ru"] subgraph TeamPlanner ["Team Planner"] SPA["📱 Frontend SPA
React, TypeScript, MUI

Веб-интерфейс для
работы с идеями"] - API["⚙️ Backend API
NestJS, TypeScript

REST API + WebSocket"] - DB[("🗄️ Database
PostgreSQL

Хранение идей,
команды, комментариев")] + API["⚙️ Backend API
NestJS, TypeScript

REST API"] + WS["🔌 WebSocket Gateway
Socket.io

Real-time обновления"] + DB[("🗄️ Database
PostgreSQL

Идеи, команда,
права, аудит")] end AI["🤖 AI Proxy Service
LLM для оценки задач"] User -->|"Использует
[HTTPS]"| SPA + Admin -->|"Управляет правами
[HTTPS]"| SPA User <-->|"OIDC Login
[Redirect]"| KC SPA -->|"API запросы
[REST + Bearer JWT]"| API + SPA <-->|"Real-time
[WebSocket]"| WS API -.->|"Валидация JWT
[JWKS]"| KC API -->|"Читает/пишет
[TypeORM]"| DB + WS -->|"Читает
[TypeORM]"| DB API -->|"Оценка трудозатрат
[HTTPS/REST]"| AI + API -->|"Уведомления
[Events]"| WS style SPA fill:#438dd5,color:#fff style API fill:#438dd5,color:#fff + style WS fill:#438dd5,color:#fff style DB fill:#438dd5,color:#fff style User fill:#08427b,color:#fff + style Admin fill:#08427b,color:#fff style AI fill:#999,color:#fff style KC fill:#c92a2a,color:#fff ``` +### 1.3 Level 3: Component Diagram — Backend API + +```mermaid +flowchart TB + subgraph API ["Backend API (NestJS)"] + subgraph Controllers ["Controllers"] + IdeasCtrl["IdeasController
CRUD идей, reorder"] + CommentsCtrl["CommentsController
Комментарии к идеям"] + TeamCtrl["TeamController
Члены команды, роли"] + AiCtrl["AiController
AI-оценка, генерация ТЗ"] + PermCtrl["PermissionsController
Управление правами"] + AuditCtrl["AuditController
История действий"] + ExportCtrl["ExportController
Экспорт в DOCX"] + end + + subgraph Services ["Services"] + IdeasSvc["IdeasService"] + CommentsSvc["CommentsService"] + TeamSvc["TeamService"] + AiSvc["AiService
Интеграция с AI Proxy"] + PermSvc["PermissionsService
Проверка/управление правами"] + AuditSvc["AuditService
Логирование действий"] + ExportSvc["ExportService
Генерация DOCX"] + end + + subgraph Gateway ["WebSocket Gateway"] + WSGateway["EventsGateway
Socket.io
Real-time события"] + end + + subgraph Auth ["Auth Module"] + JwtStrategy["JwtStrategy
JWKS валидация"] + JwtGuard["JwtAuthGuard
Глобальная защита"] + PermGuard["PermissionsGuard
Проверка прав"] + end + + subgraph Entities ["Entities (TypeORM)"] + IdeaEntity[("Idea")] + CommentEntity[("Comment")] + TeamMemberEntity[("TeamMember")] + RoleEntity[("Role")] + UserEntity[("User")] + UserPermEntity[("UserPermissions")] + AuditEntity[("AuditLog")] + end + end + + KC["🔐 Keycloak"] + AI["🤖 AI Proxy"] + DB[("PostgreSQL")] + + IdeasCtrl --> IdeasSvc + CommentsCtrl --> CommentsSvc + TeamCtrl --> TeamSvc + AiCtrl --> AiSvc + PermCtrl --> PermSvc + AuditCtrl --> AuditSvc + ExportCtrl --> ExportSvc + + IdeasSvc --> IdeaEntity + IdeasSvc --> AuditSvc + IdeasSvc --> WSGateway + CommentsSvc --> CommentEntity + CommentsSvc --> AuditSvc + TeamSvc --> TeamMemberEntity + TeamSvc --> RoleEntity + AiSvc --> IdeaEntity + AiSvc --> TeamMemberEntity + AiSvc -->|"HTTP"| AI + AiSvc --> AuditSvc + PermSvc --> UserPermEntity + PermSvc --> UserEntity + AuditSvc --> AuditEntity + ExportSvc --> IdeaEntity + ExportSvc --> CommentEntity + + IdeaEntity --> DB + CommentEntity --> DB + TeamMemberEntity --> DB + RoleEntity --> DB + UserEntity --> DB + UserPermEntity --> DB + AuditEntity --> DB + + JwtStrategy -.->|"JWKS"| KC + JwtGuard --> JwtStrategy + PermGuard --> PermSvc + + style IdeasCtrl fill:#85c1e9,color:#000 + style CommentsCtrl fill:#85c1e9,color:#000 + style TeamCtrl fill:#85c1e9,color:#000 + style AiCtrl fill:#85c1e9,color:#000 + style PermCtrl fill:#85c1e9,color:#000 + style AuditCtrl fill:#85c1e9,color:#000 + style ExportCtrl fill:#85c1e9,color:#000 + style IdeasSvc fill:#82e0aa,color:#000 + style CommentsSvc fill:#82e0aa,color:#000 + style TeamSvc fill:#82e0aa,color:#000 + style AiSvc fill:#82e0aa,color:#000 + style PermSvc fill:#82e0aa,color:#000 + style AuditSvc fill:#82e0aa,color:#000 + style ExportSvc fill:#82e0aa,color:#000 + style WSGateway fill:#f5b7b1,color:#000 + style JwtStrategy fill:#f9e79f,color:#000 + style JwtGuard fill:#f9e79f,color:#000 + style PermGuard fill:#f9e79f,color:#000 + style AI fill:#999,color:#fff + style KC fill:#c92a2a,color:#fff +``` + +### 1.4 Level 3: Component Diagram — Frontend SPA + +```mermaid +flowchart TB + subgraph SPA ["Frontend SPA (React)"] + subgraph Pages ["Pages / Views"] + IdeasPage["IdeasPage
Главная страница"] + TeamPage["TeamPage
Управление командой"] + AdminPage["AdminPage
Панель администратора"] + AuditPage["AuditPage
История действий"] + LoginPage["LoginPage
Страница входа"] + end + + subgraph Components ["UI Components"] + IdeasTable["IdeasTable
Таблица идей + D&D"] + IdeasFilters["IdeasFilters
Фильтры и поиск"] + CreateIdeaModal["CreateIdeaModal"] + AiEstimateModal["AiEstimateModal
Результат AI-оценки"] + CommentsPanel["CommentsPanel
Комментарии к идее"] + SpecModal["SpecificationModal
Просмотр/редакт. ТЗ"] + PermissionsTable["PermissionsTable
Таблица прав доступа"] + AuditLogTable["AuditLogTable
Таблица истории"] + ThemeToggle["ThemeToggle
Переключатель темы"] + OnlineUsers["OnlineUsers
Онлайн пользователи"] + end + + subgraph State ["State Management"] + IdeasStore["IdeasStore
Zustand"] + ThemeStore["ThemeStore
Zustand"] + ReactQuery["React Query
Кэш, мутации"] + end + + subgraph ServicesLayer ["Services"] + IdeasAPI["ideasApi"] + TeamAPI["teamApi"] + AiAPI["aiApi"] + CommentsAPI["commentsApi"] + PermissionsAPI["permissionsApi"] + AuditAPI["auditApi"] + ExportAPI["exportApi"] + end + + subgraph AuthLayer ["Auth"] + AuthProvider["AuthProvider
Keycloak context"] + ApiClient["api.ts
Axios + interceptors"] + end + + subgraph WebSocketLayer ["WebSocket"] + WSProvider["WebSocketProvider
Socket.io client"] + WSHooks["useWebSocket
Real-time хуки"] + end + + subgraph ThemeLayer ["Theme"] + ThemeProvider["ThemeProvider
MUI dark/light"] + end + end + + KC["🔐 Keycloak"] + API["⚙️ Backend API"] + WS["🔌 WebSocket Gateway"] + + IdeasPage --> IdeasTable + IdeasPage --> IdeasFilters + IdeasPage --> CreateIdeaModal + IdeasPage --> OnlineUsers + IdeasTable --> AiEstimateModal + IdeasTable --> CommentsPanel + IdeasTable --> SpecModal + TeamPage --> TeamAPI + AdminPage --> PermissionsTable + AuditPage --> AuditLogTable + + IdeasTable --> ReactQuery + ReactQuery --> IdeasAPI + ReactQuery --> AiAPI + ReactQuery --> CommentsAPI + ReactQuery --> PermissionsAPI + ReactQuery --> AuditAPI + ReactQuery --> ExportAPI + IdeasFilters --> IdeasStore + ThemeToggle --> ThemeStore + + IdeasAPI --> ApiClient + TeamAPI --> ApiClient + AiAPI --> ApiClient + CommentsAPI --> ApiClient + PermissionsAPI --> ApiClient + AuditAPI --> ApiClient + ExportAPI --> ApiClient + + ApiClient -->|"REST + JWT"| API + AuthProvider -->|"OIDC"| KC + WSProvider <-->|"WebSocket"| WS + WSHooks --> WSProvider + ThemeProvider --> ThemeStore + + style IdeasPage fill:#d4e6f1,color:#000 + style TeamPage fill:#d4e6f1,color:#000 + style AdminPage fill:#d4e6f1,color:#000 + style AuditPage fill:#d4e6f1,color:#000 + style LoginPage fill:#d4e6f1,color:#000 + style IdeasTable fill:#aed6f1,color:#000 + style IdeasFilters fill:#aed6f1,color:#000 + style CreateIdeaModal fill:#aed6f1,color:#000 + style AiEstimateModal fill:#aed6f1,color:#000 + style CommentsPanel fill:#aed6f1,color:#000 + style SpecModal fill:#aed6f1,color:#000 + style PermissionsTable fill:#aed6f1,color:#000 + style AuditLogTable fill:#aed6f1,color:#000 + style ThemeToggle fill:#aed6f1,color:#000 + style OnlineUsers fill:#aed6f1,color:#000 + style IdeasStore fill:#a9dfbf,color:#000 + style ThemeStore fill:#a9dfbf,color:#000 + style ReactQuery fill:#a9dfbf,color:#000 + style AuthProvider fill:#f9e79f,color:#000 + style ApiClient fill:#f9e79f,color:#000 + style WSProvider fill:#f5b7b1,color:#000 + style WSHooks fill:#f5b7b1,color:#000 + style ThemeProvider fill:#d7bde2,color:#000 + style API fill:#438dd5,color:#fff + style WS fill:#438dd5,color:#fff + style KC fill:#c92a2a,color:#fff +``` + --- ## 2. Sequence Diagrams @@ -198,7 +439,61 @@ sequenceDiagram FE-->>User: Показывает оценку ``` -### 2.5 Добавление комментария +### 2.5 Генерация мини-ТЗ + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant FE as Frontend
(React) + participant BE as Backend
(NestJS) + participant AI as AI Proxy + participant DB as PostgreSQL + + User->>FE: Нажимает "ТЗ" (генерация) + FE->>FE: Открывает модалку, показывает loader + FE->>BE: POST /api/ai/generate-specification + Note right of FE: { ideaId } + BE->>DB: SELECT idea + DB-->>BE: idea data + BE->>BE: Формирует промпт для ТЗ + BE->>AI: POST /chat/completions + Note right of BE: prompt с описанием идеи + AI-->>BE: LLM response (ТЗ в markdown) + BE->>BE: Парсит ответ + BE->>DB: UPDATE ideas SET specification = ... + DB-->>BE: OK + BE-->>FE: 200 OK { specification } + FE->>FE: Показывает ТЗ в модалке + FE-->>User: Может просмотреть/редактировать +``` + +### 2.6 Редактирование ТЗ + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant FE as Frontend
(React) + participant BE as Backend
(NestJS) + participant DB as PostgreSQL + + User->>FE: Открывает модалку ТЗ + FE-->>User: Показывает сохранённое ТЗ + User->>FE: Нажимает "Редактировать" + FE->>FE: Переключает в режим редактирования + User->>FE: Изменяет текст ТЗ + User->>FE: Нажимает "Сохранить" + FE->>BE: PATCH /api/ideas/:id + Note right of FE: { specification: "..." } + BE->>DB: UPDATE ideas SET specification = ... + DB-->>BE: updated idea + BE-->>FE: 200 OK { idea } + FE->>FE: Обновляет store + FE-->>User: Показывает обновлённое ТЗ +``` + +### 2.7 Добавление комментария ```mermaid sequenceDiagram @@ -239,6 +534,149 @@ sequenceDiagram FE-->>User: Отображает таблицу ``` +### 2.9 Real-time обновления (WebSocket) + +```mermaid +sequenceDiagram + autonumber + actor User1 as Пользователь 1 + actor User2 as Пользователь 2 + participant FE1 as Frontend 1 + participant FE2 as Frontend 2 + participant WS as WebSocket Gateway + participant BE as Backend + participant DB as PostgreSQL + + Note over FE1,FE2: Оба пользователя подключены к WebSocket + + FE1->>WS: connect(token) + WS->>WS: Валидация JWT + WS-->>FE1: connected + + FE2->>WS: connect(token) + WS-->>FE2: connected + + User1->>FE1: Редактирует идею + FE1->>BE: PATCH /api/ideas/:id + BE->>DB: UPDATE ideas + DB-->>BE: OK + BE->>WS: emit('idea:updated', idea) + WS-->>FE1: idea:updated + WS-->>FE2: idea:updated + FE1->>FE1: Обновляет store + FE2->>FE2: Обновляет store + FE2-->>User2: Видит изменения в реальном времени +``` + +### 2.10 Проверка прав доступа + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant FE as Frontend + participant BE as Backend + participant DB as PostgreSQL + + User->>FE: Пытается создать идею + FE->>BE: POST /api/ideas + Note right of FE: Authorization: Bearer token + BE->>BE: JwtAuthGuard: валидация JWT + BE->>DB: SELECT * FROM user_permissions WHERE userId = ? + DB-->>BE: permissions + BE->>BE: PermissionsGuard: проверка 'create_ideas' + + alt Право есть + BE->>DB: INSERT INTO ideas + BE->>DB: INSERT INTO audit_log + DB-->>BE: OK + BE-->>FE: 201 Created { idea } + else Права нет + BE-->>FE: 403 Forbidden { message: "Недостаточно прав" } + FE-->>User: Показывает ошибку + end +``` + +### 2.11 Изменение прав пользователя (Админ) + +```mermaid +sequenceDiagram + autonumber + actor Admin as Администратор + participant FE as Frontend + participant BE as Backend + participant DB as PostgreSQL + + Admin->>FE: Открывает панель администратора + FE->>BE: GET /api/permissions/users + BE->>BE: Проверка: isAdmin(user.email) + BE->>DB: SELECT * FROM users + permissions + DB-->>BE: users with permissions + BE-->>FE: 200 OK { users } + FE-->>Admin: Показывает таблицу прав + + Admin->>FE: Изменяет право пользователя + FE->>BE: PATCH /api/permissions/:userId + Note right of FE: { create_ideas: true } + BE->>BE: Проверка: isAdmin + BE->>DB: UPDATE user_permissions + BE->>DB: INSERT INTO audit_log + DB-->>BE: OK + BE-->>FE: 200 OK { permissions } + FE-->>Admin: Обновляет UI +``` + +### 2.12 Просмотр и восстановление из аудита + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant FE as Frontend + participant BE as Backend + participant DB as PostgreSQL + + User->>FE: Открывает историю действий + FE->>BE: GET /api/audit?entityType=idea&page=1 + BE->>BE: Проверка права 'view_audit_log' + BE->>DB: SELECT * FROM audit_log WHERE ... + DB-->>BE: audit entries + BE-->>FE: 200 OK { data, meta } + FE-->>User: Показывает таблицу истории + + User->>FE: Нажимает "Восстановить" на удалённой идее + FE->>BE: POST /api/audit/:id/restore + BE->>DB: SELECT oldValue FROM audit_log + DB-->>BE: { oldValue: {...} } + BE->>DB: INSERT INTO ideas (oldValue data) + BE->>DB: INSERT INTO audit_log (restore action) + DB-->>BE: OK + BE-->>FE: 201 Created { idea } + FE->>FE: Обновляет список идей + FE-->>User: Идея восстановлена +``` + +### 2.13 Экспорт идеи в DOCX + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant FE as Frontend + participant BE as Backend + participant DB as PostgreSQL + + User->>FE: Нажимает "Экспорт" на идее + FE->>BE: GET /api/export/idea/:id + BE->>BE: Проверка права 'export_ideas' + BE->>DB: SELECT idea + comments + estimate + DB-->>BE: full idea data + BE->>BE: Генерирует DOCX (docx library) + BE-->>FE: 200 OK (binary, Content-Type: application/vnd.openxmlformats...) + FE->>FE: Скачивает файл + FE-->>User: Файл скачан +``` + --- ## 3. API Contracts @@ -503,6 +941,47 @@ AI-оценка трудозатрат для идеи. } ``` +#### POST /api/ai/generate-specification +AI-генерация мини-ТЗ для идеи. + +**Request Body:** +```typescript +{ + ideaId: string; +} +``` + +**Response 200:** +```typescript +{ + ideaId: string; + specification: string; // markdown текст ТЗ + generatedAt: string; // ISO timestamp +} +``` + +**Структура генерируемого ТЗ:** +```markdown +## Цель +[что должно быть достигнуто] + +## Функциональные требования +- [требование 1] +- [требование 2] +... + +## Технические требования +[архитектура, интеграции, ограничения] + +## Критерии приёмки +- [критерий 1] +- [критерий 2] +... + +## Зависимости и риски +[что может повлиять на реализацию] +``` + ### 3.5 Enums ```typescript @@ -549,6 +1028,225 @@ enum Complexity { HARD = 'hard', EPIC = 'epic' } + +enum Permission { + VIEW_IDEAS = 'view_ideas', + CREATE_IDEAS = 'create_ideas', + EDIT_OWN_IDEAS = 'edit_own_ideas', + EDIT_ANY_IDEAS = 'edit_any_ideas', + DELETE_OWN_IDEAS = 'delete_own_ideas', + DELETE_ANY_IDEAS = 'delete_any_ideas', + REORDER_IDEAS = 'reorder_ideas', + ADD_COMMENTS = 'add_comments', + DELETE_OWN_COMMENTS = 'delete_own_comments', + DELETE_ANY_COMMENTS = 'delete_any_comments', + REQUEST_AI_ESTIMATE = 'request_ai_estimate', + REQUEST_AI_SPECIFICATION = 'request_ai_specification', + EDIT_SPECIFICATION = 'edit_specification', + DELETE_AI_GENERATIONS = 'delete_ai_generations', + MANAGE_TEAM = 'manage_team', + MANAGE_ROLES = 'manage_roles', + EXPORT_IDEAS = 'export_ideas', + VIEW_AUDIT_LOG = 'view_audit_log' +} + +enum AuditAction { + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete', + GENERATE = 'generate', + RESTORE = 'restore' +} + +enum EntityType { + IDEA = 'idea', + COMMENT = 'comment', + SPECIFICATION = 'specification', + ESTIMATE = 'estimate', + TEAM_MEMBER = 'team_member', + USER_PERMISSIONS = 'user_permissions' +} +``` + +### 3.6 Permissions + +#### GET /api/permissions/me +Получение прав текущего пользователя. + +**Response 200:** +```typescript +{ + userId: string; + email: string; + isAdmin: boolean; + permissions: Record; +} +``` + +#### GET /api/permissions/users +Получение списка пользователей с правами (только для админа). + +**Response 200:** +```typescript +{ + data: { + userId: string; + email: string; + name: string; + isAdmin: boolean; + permissions: Record; + lastLogin: string; + }[]; +} +``` + +#### PATCH /api/permissions/:userId +Изменение прав пользователя (только для админа). + +**Request Body:** +```typescript +Partial> +``` + +**Response 200:** +```typescript +{ + userId: string; + permissions: Record; +} +``` + +### 3.7 Audit + +#### GET /api/audit +Получение истории действий. + +**Query Parameters:** +```typescript +{ + page?: number; // default: 1 + limit?: number; // default: 50 + userId?: string; // фильтр по пользователю + action?: AuditAction; // фильтр по действию + entityType?: EntityType; // фильтр по типу сущности + entityId?: string; // фильтр по ID сущности + from?: string; // ISO date - начало периода + to?: string; // ISO date - конец периода +} +``` + +**Response 200:** +```typescript +{ + data: { + id: string; + userId: string; + userName: string; + action: AuditAction; + entityType: EntityType; + entityId: string; + oldValue: object | null; + newValue: object | null; + timestamp: string; + }[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + } +} +``` + +#### POST /api/audit/:id/restore +Восстановление сущности из аудита. + +**Response 201:** +```typescript +{ + restored: true; + entity: Idea | Comment | TeamMember; // восстановленная сущность +} +``` + +#### GET /api/audit/settings +Получение настроек аудита (только для админа). + +**Response 200:** +```typescript +{ + retentionDays: number; // срок хранения в днях +} +``` + +#### PATCH /api/audit/settings +Изменение настроек аудита (только для админа). + +**Request Body:** +```typescript +{ + retentionDays: number; // 1-365 +} +``` + +**Response 200:** +```typescript +{ + retentionDays: number; +} +``` + +### 3.8 Export + +#### GET /api/export/idea/:id +Экспорт идеи в DOCX. + +**Response 200:** +``` +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document +Content-Disposition: attachment; filename="idea-{title}.docx" + +Binary DOCX content +``` + +### 3.9 WebSocket Events + +#### События от сервера (server → client) + +```typescript +// Идеи +'idea:created' → { idea: Idea } +'idea:updated' → { idea: Idea } +'idea:deleted' → { ideaId: string } +'ideas:reordered' → { ids: string[] } + +// Комментарии +'comment:created' → { comment: Comment, ideaId: string } +'comment:deleted' → { commentId: string, ideaId: string } + +// AI +'specification:generated' → { ideaId: string, specification: string } +'estimate:generated' → { ideaId: string, estimate: Estimate } + +// Присутствие +'users:online' → { users: { id: string, name: string, avatar?: string }[] } +'user:joined' → { user: { id: string, name: string } } +'user:left' → { userId: string } + +// Редактирование +'idea:editing' → { ideaId: string, userId: string, userName: string } +'idea:stopEditing' → { ideaId: string, userId: string } +``` + +#### События от клиента (client → server) + +```typescript +// Присоединение +'join' → { token: string } + +// Редактирование +'startEditing' → { ideaId: string } +'stopEditing' → { ideaId: string } ``` --- @@ -723,13 +1421,217 @@ enum Complexity { └─────────────────────────────────────────────────────────────────────────────┘ ``` +### 4.6 Модальное окно мини-ТЗ + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 📋 Техническое задание [✕] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Идея: Добавить тёмную тему │ +│ Сгенерировано: 15.01.2026, 12:30 │ +│ │ +│ ┌─ Содержание ТЗ ─────────────────────────────────────────────────────────┐│ +│ │ ││ +│ │ ## Цель ││ +│ │ Реализовать тёмную тему оформления для снижения нагрузки на глаза ││ +│ │ пользователей при работе в условиях низкой освещённости. ││ +│ │ ││ +│ │ ## Функциональные требования ││ +│ │ - Переключатель темы в настройках пользователя ││ +│ │ - Автоматическое определение системной темы ││ +│ │ - Сохранение выбора пользователя ││ +│ │ ││ +│ │ ## Технические требования ││ +│ │ - CSS custom properties для цветовой схемы ││ +│ │ - Поддержка prefers-color-scheme ││ +│ │ - localStorage для сохранения выбора ││ +│ │ ││ +│ │ ## Критерии приёмки ││ +│ │ - [ ] Все элементы UI корректно отображаются в тёмной теме ││ +│ │ - [ ] Переключение работает мгновенно без перезагрузки ││ +│ │ - [ ] Выбор сохраняется между сессиями ││ +│ │ ││ +│ │ ## Зависимости и риски ││ +│ │ - Необходимо согласование цветовой палитры с дизайнером ││ +│ │ - Возможны проблемы с контрастностью на некоторых элементах ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ [✏️ Редактировать] [Закрыть] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Режим редактирования: +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 📋 Редактирование ТЗ [✕] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Идея: Добавить тёмную тему │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ ## Цель ││ +│ │ Реализовать тёмную тему оформления для снижения нагрузки... ││ +│ │ ││ +│ │ ## Функциональные требования ││ +│ │ - Переключатель темы в настройках пользователя ││ +│ │ ... ││ +│ │ [textarea, multiline]││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ [Отмена] [💾 Сохранить] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Режим генерации (loading): +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 📋 Техническое задание [✕] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Идея: Добавить тёмную тему │ +│ │ +│ │ +│ Генерируем техническое задание... │ +│ │ +│ ═══════════════════ │ +│ [progress bar] │ +│ │ +│ │ +│ [Отмена] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.7 Панель администратора — Управление правами + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 🎯 Team Planner [☀️/🌙] [👥 2 онлайн] [Админ] [Выход] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [Идеи] [Команда] [Администрирование] [История] │ +│ ───────────────────── │ +│ │ +│ ┌─ Управление правами пользователей ──────────────────────────────────────┐│ +│ │ ││ +│ │ ┌─────────────────────────────────────────────────────────────────────┐││ +│ │ │ Пользователь │ Послед. вход │ Идеи │ Свои │ Чужие│ AI │ ... │││ +│ │ ├───────────────────┼──────────────┼──────┼──────┼──────┼─────┼─────┤││ +│ │ │ 👤 Иван Петров │ 10 мин назад │ ✅ │ ✅ │ ❌ │ ✅ │ │││ +│ │ │ ivan@mail.ru │ │ │ │ │ │ │││ +│ │ │ 👤 Мария Сидорова │ 2 дня назад │ ✅ │ ✅ │ ✅ │ ✅ │ │││ +│ │ │ maria@mail.ru │ │ │ │ │ │ │││ +│ │ │ 👤 Новый юзер │ Никогда │ ✅ │ ❌ │ ❌ │ ❌ │ │││ +│ │ │ new@mail.ru │ (только view)│ │ │ │ │ │││ +│ │ └─────────────────────────────────────────────────────────────────────┘││ +│ │ ││ +│ │ Легенда колонок: ││ +│ │ Идеи = create_ideas, Свои = edit_own_ideas, Чужие = edit_any_ideas ││ +│ │ AI = request_ai_estimate + request_ai_specification ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─ Настройки аудита ──────────────────────────────────────────────────────┐│ +│ │ ││ +│ │ Срок хранения истории: [30] дней [Сохранить] ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.8 История действий (Аудит) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 🎯 Team Planner [☀️/🌙] [👥 2 онлайн] [Иван] [Выход] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [Идеи] [Команда] [Администрирование] [История] │ +│ ──────── │ +│ │ +│ ┌─ Фильтры ───────────────────────────────────────────────────────────────┐│ +│ │ [Пользователь ▼] [Действие ▼] [Сущность ▼] [С: 📅] [По: 📅] [Поиск...] ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─ История действий ──────────────────────────────────────────────────────┐│ +│ │ ││ +│ │ ┌─────────────────────────────────────────────────────────────────────┐││ +│ │ │ Дата/Время │ Пользователь │ Действие │ Сущность │ Детали │││ +│ │ ├──────────────────┼──────────────┼──────────┼──────────┼───────────┤││ +│ │ │ 15.01 14:30 │ Иван │ ✏️ update│ Идея │ [👁️ Diff]│││ +│ │ │ 15.01 14:25 │ Мария │ ➕ create│ Коммент. │ [👁️] │││ +│ │ │ 15.01 14:20 │ Иван │ 🗑️ delete│ Идея │ [🔄 Восст]││ +│ │ │ 15.01 14:15 │ AI │ 🤖 gen. │ ТЗ │ [👁️] │││ +│ │ │ 15.01 14:10 │ Мария │ ➕ create│ Идея │ [👁️] │││ +│ │ └─────────────────────────────────────────────────────────────────────┘││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ Показано 5 из 128 [< Пред] 1 2 3 [След >] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Просмотр изменений (diff): +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 📋 Детали изменения [✕] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Пользователь: Иван Петров │ +│ Дата: 15.01.2026, 14:30 │ +│ Действие: Обновление идеи │ +│ │ +│ ┌─ Изменения ─────────────────────────────────────────────────────────────┐│ +│ │ ││ +│ │ Статус: 🟢 Новая → 🟡 В обсуждении ││ +│ │ Приоритет: Средний → Высокий ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ [Закрыть] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.9 Главная страница с онлайн-пользователями и темой + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 🎯 Team Planner [☀️ Светлая / 🌙 Тёмная] [👥 3] [Иван] [⎋ Выход] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ┌───────────────────┐ │ +│ [Идеи] [Команда] [История] Онлайн сейчас:│ 🟢 Иван │ │ +│ ─────── │ 🟢 Мария │ │ +│ │ 🟢 Алексей │ │ +│ ┌─ Фильтры ──────────────────────┐ └───────────────────┘ │ +│ │ [Статус ▼] [Приоритет ▼] ... │ │ +│ └────────────────────────────────┘ │ +│ │ +│ ┌─ Таблица идей ───────────────────────────────────────────────────────┐ │ +│ │ ⋮⋮│Статус │⚡│Модуль │Идея │Автор │Оценка│ │ │ │ +│ ├───┼─────────┼──┼───────┼──────────────┼──────┼──────┼──────┼─────────┤ │ +│ │⋮⋮ │🟢 Новая │🔴│Front │Тёмная тема │Иван │3д │[📋][⬇️]│[🗑️] │ │ +│ │ │ │ │ │✏️ Мария ред. │ │ │ │ │ │ +│ │⋮⋮ │🟡 Обсужд│🟡│Back │API кэш │Мария │5д │[📋][⬇️]│[🗑️] │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [+ Новая идея] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Легенда: +📋 - Кнопка ТЗ +⬇️ - Кнопка экспорта в DOCX +✏️ Мария ред. - индикатор что Мария сейчас редактирует эту идею +``` + --- ## 5. UI Specification ### 5.1 Цветовая палитра -#### Основные цвета +#### Основные цвета (Светлая тема) ``` Primary: #1976D2 (MUI Blue 700) Primary Light: #42A5F5 (MUI Blue 400) @@ -739,6 +1641,23 @@ Secondary: #9C27B0 (MUI Purple 500) Background: #FFFFFF Surface: #F5F5F5 (Grey 100) +Text Primary: #212121 (Grey 900) +Text Secondary: #757575 (Grey 600) +``` + +#### Основные цвета (Тёмная тема) +``` +Primary: #90CAF9 (MUI Blue 200) +Primary Light: #E3F2FD (MUI Blue 50) +Primary Dark: #42A5F5 (MUI Blue 400) + +Secondary: #CE93D8 (MUI Purple 200) + +Background: #121212 +Surface: #1E1E1E +Paper: #2D2D2D +Text Primary: #FFFFFF +Text Secondary: #B0B0B0 ``` #### Статусы идей @@ -1060,6 +1979,10 @@ Web origins: http://localhost:4000 **Backend (.env):** ``` KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner +ADMIN_EMAIL=admin@vigdorov.ru # email администратора из K8s Secret +AI_PROXY_BASE_URL=http://ai-proxy:3000 # URL AI Proxy сервиса +AI_PROXY_API_KEY=... # API ключ для AI Proxy +AUDIT_RETENTION_DAYS=30 # срок хранения аудита (по умолчанию) ``` **Frontend (.env):** @@ -1067,6 +1990,7 @@ KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner VITE_KEYCLOAK_URL=https://auth.vigdorov.ru VITE_KEYCLOAK_REALM=team-planner VITE_KEYCLOAK_CLIENT_ID=team-planner-frontend +VITE_WS_URL=wss://team-planner.vigdorov.ru # WebSocket URL ``` ### 7.4 JWT Validation (Backend) @@ -1087,12 +2011,21 @@ algorithms: ['RS256'] ### 7.5 Защищённые и публичные endpoints -| Endpoint | Доступ | Декоратор | -|----------|--------|-----------| +| Endpoint | Доступ | Право / Декоратор | +|----------|--------|-------------------| | `GET /` | Public | `@Public()` | | `GET /health` | Public | `@Public()` | -| `GET /api/ideas` | Protected | — | -| `POST /api/ideas` | Protected | — | -| `PATCH /api/ideas/:id` | Protected | — | -| `DELETE /api/ideas/:id` | Protected | — | -| Все остальные | Protected | — | +| `GET /api/ideas` | Protected | `view_ideas` | +| `POST /api/ideas` | Protected | `create_ideas` | +| `PATCH /api/ideas/:id` | Protected | `edit_own_ideas` / `edit_any_ideas` | +| `DELETE /api/ideas/:id` | Protected | `delete_own_ideas` / `delete_any_ideas` | +| `PATCH /api/ideas/reorder` | Protected | `reorder_ideas` | +| `POST /api/ai/estimate` | Protected | `request_ai_estimate` | +| `POST /api/ai/generate-specification` | Protected | `request_ai_specification` | +| `GET /api/permissions/me` | Protected | — (все пользователи) | +| `GET /api/permissions/users` | Protected | Admin only | +| `PATCH /api/permissions/:userId` | Protected | Admin only | +| `GET /api/audit` | Protected | `view_audit_log` | +| `POST /api/audit/:id/restore` | Protected | Admin only | +| `GET /api/export/idea/:id` | Protected | `export_ideas` | +| WebSocket | Protected | JWT в handshake | diff --git a/CONTEXT.md b/CONTEXT.md index bb7ae77..65d64d7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,8 +6,9 @@ ## Текущий статус -**Этап:** Фаза 2 завершена ✅, E2E тесты готовы ✅, далее Фаза 3 (AI-интеграция) -**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал готовы +**Этап:** Фаза 3.1 завершена ✅ | Новые требования (Фазы 4-8) запланированы 📋 +**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ готовы +**Следующий этап:** Фаза 4 — Права доступа **Последнее обновление:** 2026-01-15 --- @@ -58,6 +59,28 @@ | 2026-01-15 | **Testing:** Рефакторинг тестов на data-testid — стабильные селекторы вместо tbody/tr/.nth() | | 2026-01-15 | **Testing:** Добавлены data-testid во все компоненты фронтенда (IdeasTable, TeamPage, CommentsPanel и др.) | | 2026-01-15 | **Docs:** Создан E2E_TESTING.md — гайд по написанию e2e тестов, соглашения по data-testid | +| 2026-01-15 | **Фаза 3:** Backend AI модуль (ai.service.ts, ai.controller.ts, POST /api/ai/estimate) | +| 2026-01-15 | **Фаза 3:** Миграция AddAiEstimateFields — поля estimatedHours, complexity, estimateDetails, estimatedAt в Idea | +| 2026-01-15 | **Фаза 3:** Frontend AI сервис (services/ai.ts, hooks/useAi.ts) | +| 2026-01-15 | **Фаза 3:** Frontend AiEstimateModal — модалка с результатом оценки (часы, сложность, разбивка по ролям, рекомендации) | +| 2026-01-15 | **Фаза 3:** Кнопка AI-оценки в таблице идей (AutoAwesome icon) + колонка "Оценка" | +| 2026-01-15 | **Infra:** Добавлены AI_PROXY_BASE_URL, AI_PROXY_API_KEY в k8s/backend-deployment.yaml | +| 2026-01-15 | **Testing:** E2E тесты Фазы 3 (Playwright) — 11 тестов покрывают AI-оценку (модалка, загрузка, результат, разбивка, просмотр) | +| 2026-01-15 | **Фаза 3:** Просмотр сохранённых результатов AI-оценки — клик по ячейке "Оценка" открывает модалку с деталями | +| 2026-01-15 | **Фаза 3.1:** Backend миграция для полей specification, specificationGeneratedAt | +| 2026-01-15 | **Фаза 3.1:** Backend POST /api/ai/generate-specification endpoint + buildSpecificationPrompt | +| 2026-01-15 | **Фаза 3.1:** Backend обновлён buildPrompt() — включает ТЗ в AI-оценку для лучшей точности | +| 2026-01-15 | **Фаза 3.1:** Frontend SpecificationModal компонент (генерация/просмотр/редактирование ТЗ) | +| 2026-01-15 | **Фаза 3.1:** Frontend кнопка ТЗ в таблице (Description icon) — серая если нет ТЗ, синяя если есть | +| 2026-01-15 | **Фаза 3.1:** Frontend интеграция useGenerateSpecification hook + сохранение редактированного ТЗ | +| 2026-01-15 | **Testing:** E2E тесты Фазы 3.1 (Playwright) — 9 тестов покрывают генерацию, просмотр, редактирование ТЗ | +| 2026-01-15 | **Фаза 3.1:** Markdown-рендеринг ТЗ в режиме просмотра (react-markdown), raw markdown в режиме редактирования | +| 2026-01-15 | **Фаза 3.1:** История ТЗ — SpecificationHistory entity, миграция, GET/DELETE/POST restore endpoints | +| 2026-01-15 | **Фаза 3.1:** Frontend история ТЗ — табы (Текущее ТЗ / История), просмотр/восстановление/удаление версий | +| 2026-01-15 | **Фаза 3.1:** При перегенерации ТЗ старая версия автоматически сохраняется в историю | +| 2026-01-15 | **Фаза 3.1:** AI-промпты (ТЗ и оценка) теперь учитывают комментарии к идее | +| 2026-01-15 | **Планирование:** Добавлены новые требования — права доступа, аудит, WebSocket, темная тема, экспорт | +| 2026-01-15 | **Документация:** Обновлены REQUIREMENTS.md, ARCHITECTURE.md, ROADMAP.md — добавлены Фазы 4-8 | --- @@ -65,7 +88,33 @@ > Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки -**Сейчас:** Фаза 2 завершена ✅ — далее Фаза 3 (AI-интеграция) +**Готово:** Фазы 0-3.1 завершены ✅ +**Следующий шаг:** Фаза 4 — Права доступа 📋 + +### Новые требования (Фазы 4-8): + +**Фаза 4: Права доступа** +- [ ] Гранулярные права (18 различных прав) +- [ ] Панель администратора +- [ ] Автор идеи (readonly) +- [ ] Admin определяется через K8s Secret + +**Фаза 5: Аудит и история** +- [ ] Логирование всех действий +- [ ] Восстановление удалённых данных +- [ ] Настраиваемый срок хранения (по умолчанию 30 дней) + +**Фаза 6: Real-time и WebSocket** +- [ ] Многопользовательская работа +- [ ] Индикаторы присутствия +- [ ] Конкурентное редактирование + +**Фаза 7: Темная тема** +- [ ] Переключатель светлая/тёмная +- [ ] Автоопределение системной темы + +**Фаза 8: Экспорт** +- [ ] Экспорт идеи в DOCX --- @@ -94,24 +143,32 @@ team-planner/ ├── tests/ │ ├── package.json # Зависимости для тестов │ ├── playwright.config.ts # Конфигурация Playwright -│ └── e2e/ # Playwright E2E тесты (54 теста) ✅ +│ └── e2e/ # Playwright E2E тесты ✅ │ ├── auth.setup.ts # Авторизация для тестов (Keycloak) │ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов) -│ └── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда) +│ ├── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда) +│ └── phase3.spec.ts # Тесты Фазы 3 (20 тестов — AI-оценка + мини-ТЗ) ├── backend/ # NestJS API │ ├── src/ │ │ ├── auth/ # Модуль авторизации ✅ │ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS │ │ │ ├── jwt-auth.guard.ts # Глобальный guard │ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints -│ │ ├── ideas/ # Модуль идей (готов + reorder) +│ │ ├── ideas/ # Модуль идей (готов + reorder + history) +│ │ │ ├── entities/ +│ │ │ │ ├── idea.entity.ts # Idea + specification поля +│ │ │ │ └── specification-history.entity.ts # История ТЗ ✅ │ │ │ ├── dto/ │ │ │ │ └── reorder-ideas.dto.ts # DTO для изменения порядка │ │ │ ├── ideas.controller.ts # PATCH /ideas/reorder │ │ │ └── ideas.service.ts # reorder() с транзакцией │ │ ├── team/ # Модуль команды (Фаза 2) — TeamMember + Role entities │ │ ├── comments/ # Модуль комментариев (Фаза 2) -│ │ └── ai/ # AI-оценка (Фаза 3) +│ │ └── ai/ # AI-оценка + мини-ТЗ + история (Фаза 3 + 3.1) ✅ +│ │ ├── ai.module.ts +│ │ ├── ai.service.ts # estimateIdea + generateSpecification + history + комментарии в промптах +│ │ ├── ai.controller.ts # /estimate, /generate-specification, /specification-history/* +│ │ └── dto/ │ └── ... └── frontend/ # React приложение ├── src/ @@ -129,16 +186,20 @@ team-planner/ │ │ │ ├── TeamPage.tsx # Табы: Участники / Роли │ │ │ ├── TeamMemberModal.tsx # Модалка участника │ │ │ └── RolesManager.tsx # Управление ролями - │ │ └── CommentsPanel/ # Комментарии к идеям + │ │ ├── CommentsPanel/ # Комментарии к идеям + │ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅ + │ │ └── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅ │ ├── hooks/ - │ │ └── useIdeas.ts # React Query хуки + useReorderIdeas + │ │ ├── useIdeas.ts # React Query хуки + useReorderIdeas + │ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅ │ ├── services/ │ │ ├── api.ts # Axios + auth interceptors │ │ ├── keycloak.ts # Keycloak instance ✅ │ │ ├── ideas.ts # API методы + reorder() │ │ ├── team.ts # API команды │ │ ├── roles.ts # API ролей - │ │ └── comments.ts # API комментариев + │ │ ├── comments.ts # API комментариев + │ │ └── ai.ts # AI Proxy API (Фаза 3 + 3.1) ✅ │ ├── store/ │ │ └── ideas.ts # Zustand store │ └── types/ @@ -176,8 +237,10 @@ team-planner/ - **Интерфейс на русском языке** — все тексты, лейблы, placeholder'ы должны быть на русском - AI-интеграция через ai-proxy: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md` -- Многопользовательский режим НЕ нужен -- Экспорт и интеграции НЕ нужны +- **Многопользовательский режим НУЖЕН** — WebSocket, real-time обновления (Фаза 6) +- **Экспорт НУЖЕН** — экспорт идеи в DOCX (Фаза 8) +- **Права доступа НУЖНЫ** — гранулярная система прав, панель админа (Фаза 4) +- **Аудит НУЖЕН** — история действий с восстановлением (Фаза 5) - Warning о React Compiler и TanStack Table можно игнорировать - **Drag & Drop:** dnd-kit с useSortable + @dnd-kit/modifiers (restrictToVerticalAxis), DragHandle через React Context, CSS.Translate для совместимости с таблицами, reorder через транзакцию - **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend` @@ -185,3 +248,6 @@ team-planner/ - **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak) - **CI/CD:** Drone CI (.drone.yml) — сборка backend/frontend/keycloak-theme, деплой в K8s namespace `team-planner` - **E2E тесты:** Все компоненты имеют `data-testid` для стабильных селекторов. Перед написанием тестов читай E2E_TESTING.md! +- **AI Proxy:** Интеграция с ai-proxy-service (K8s namespace `ai-proxy`), модель `claude-3.7-sonnet`, env: AI_PROXY_BASE_URL, AI_PROXY_API_KEY +- **История ТЗ:** При перегенерации старая версия сохраняется в `specification_history`, можно просмотреть/восстановить/удалить +- **Комментарии в AI:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fea796c..551e227 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -17,11 +17,12 @@ ## Локальное окружение ### Порты -| Сервис | Порт | -|--------|------| -| Frontend (React) | 4000 | -| Backend (NestJS) | 4001 | -| PostgreSQL | 5432 | +| Сервис | Порт | Описание | +|--------|------|----------| +| Frontend (React) | 4000 | Vite dev server | +| Backend (NestJS) | 4001 | NestJS API | +| PostgreSQL | 5432 | Docker container | +| AI Proxy (туннель) | 3000 | SSH туннель к K8s | ### База данных PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в корне проекта. @@ -31,6 +32,63 @@ PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в к docker-compose up -d postgres ``` +### Настройка Backend + +Создай файл `backend/.env`: + +```bash +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=teamplanner +DB_PASSWORD=teamplanner +DB_DATABASE=teamplanner + +# Keycloak +KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner + +# AI Proxy (для Фазы 3) +AI_PROXY_BASE_URL=http://localhost:3000 +AI_PROXY_API_KEY= +``` + +### AI Proxy — port-forward + +Для локальной работы с AI Proxy нужен port-forward: + +```bash +# Запуск port-forward (в отдельном терминале или в фоне) +kubectl port-forward svc/ai-proxy-service 3000:3000 -n ai-proxy +``` + +Проверка: +```bash +curl http://localhost:3000/health +# {"status":"ok","service":"ai-proxy-service","version":"0.0.1",...} +``` + +**Примечание:** kubectl настроен для доступа к production кластеру. + +--- + +## Работа с Production кластером + +kubectl настроен для доступа к production кластеру: + +```bash +# Проверка статуса приложения +kubectl get pods -n team-planner + +# Просмотр логов +kubectl logs -f deployment/team-planner-backend -n team-planner + +# Проверка AI Proxy +kubectl get pods -n ai-proxy +kubectl logs -f deployment/ai-proxy-service -n ai-proxy +``` + +**⚠️ Внимание:** Будьте осторожны при работе с production окружением! + --- ## Правила работы diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 7b903d9..f28b9b7 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -27,21 +27,33 @@ | Цвет | Цветовая маркировка строки | Color | | Оценка времени | AI-генерируемая оценка трудозатрат | Calculated | -#### 1.2 Редактирование идей +#### 1.2 Автор идеи +- При создании идеи автоматически сохраняется автор (текущий пользователь) +- Автора идеи изменить нельзя (поле readonly) +- Отображение автора в таблице и детальном просмотре + +#### 1.3 Редактирование идей - **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом - **Быстрое изменение статуса и приоритета** через dropdown - **Автосохранение** изменений -#### 1.3 Drag & Drop +#### 1.4 Drag & Drop - Перемещение идей в списке для ручной сортировки - Визуальная индикация при перетаскивании - Сохранение порядка после перемещения -#### 1.4 Цветовая маркировка +#### 1.5 Цветовая маркировка - Возможность назначить цвет строке для визуального выделения - Предустановленная палитра цветов - Фильтрация по цвету +#### 1.6 Экспорт идеи +- Экспорт отдельной идеи в формате DOCX +- Включает: название, описание, статус, приоритет, модуль, целевую аудиторию, боль, роль AI, способ проверки +- Если есть AI-оценка — включается в документ (общее время, сложность, разбивка по ролям, рекомендации) +- Если есть ТЗ — включается в документ (markdown рендерится как форматированный текст) +- Комментарии к идее включаются в документ (автор, дата, текст) + ### 2. Сортировка и фильтрация #### 2.1 Сортировка @@ -89,6 +101,14 @@ - Расчёт общего времени с учётом состава команды - Рекомендации по оптимизации +#### 3.4 Генерация мини-ТЗ +- **Генерация ТЗ**: создание структурированного технического задания на основе описания идеи +- **Структура ТЗ**: цель, функциональные требования, технические требования, критерии приёмки, зависимости и риски +- **Сохранение**: ТЗ сохраняется в базе данных для повторного использования +- **Просмотр**: возможность просмотреть сохранённое ТЗ по клику на кнопку +- **Редактирование**: возможность изменить сгенерированное ТЗ вручную +- **Интеграция с оценкой**: AI-оценка времени учитывает ТЗ для более точного расчёта + ### 4. Комментарии - Добавление комментариев к идее @@ -96,6 +116,101 @@ - Упоминание участников (@mention) - История комментариев +### 5. Система прав доступа + +#### 5.1 Роли пользователей +- **Администратор** — единственный пользователь с полными правами, логин задаётся в секретах кластера (K8s Secret) +- **Обычный пользователь** — новый пользователь после первого входа получает только права на просмотр +- Администратор может изменять права любого пользователя (кроме себя) + +#### 5.2 Гранулярные права доступа +Каждое право настраивается отдельно: + +| Право | Описание | +|-------|----------| +| `view_ideas` | Просмотр списка идей (по умолчанию: ✅) | +| `create_ideas` | Создание новых идей | +| `edit_own_ideas` | Редактирование своих идей | +| `edit_any_ideas` | Редактирование чужих идей | +| `delete_own_ideas` | Удаление своих идей | +| `delete_any_ideas` | Удаление чужих идей | +| `reorder_ideas` | Изменение порядка идей (drag & drop) | +| `add_comments` | Добавление комментариев | +| `delete_own_comments` | Удаление своих комментариев | +| `delete_any_comments` | Удаление чужих комментариев | +| `request_ai_estimate` | Запрос AI-оценки трудозатрат | +| `request_ai_specification` | Запрос AI-генерации ТЗ | +| `edit_specification` | Редактирование ТЗ | +| `delete_ai_generations` | Удаление AI-генераций (оценки, ТЗ) | +| `manage_team` | Управление командой (добавление/удаление участников) | +| `manage_roles` | Управление ролями команды | +| `export_ideas` | Экспорт идей в документы | +| `view_audit_log` | Просмотр истории действий | + +#### 5.3 Панель администратора +- Доступна только администратору +- Таблица пользователей с их правами +- Чекбоксы для включения/выключения каждого права +- Применение изменений сохраняется немедленно + +### 6. История действий (Аудит) + +#### 6.1 Логирование действий +- Любые манипуляции с данными фиксируются: создание, редактирование, удаление идей, генерации AI, комментарии +- Сохраняется: кто сделал, что сделал, когда, старое значение, новое значение + +#### 6.2 Формат записи аудита +| Поле | Описание | +|------|----------| +| id | Уникальный идентификатор записи | +| userId | ID пользователя | +| userName | Имя пользователя | +| action | Тип действия (create, update, delete, generate, restore) | +| entityType | Тип сущности (idea, comment, specification, estimate, team_member) | +| entityId | ID сущности | +| oldValue | Значение до изменения (JSON) | +| newValue | Значение после изменения (JSON) | +| timestamp | Дата и время действия | + +#### 6.3 Просмотр истории +- Страница истории действий (только для админа или пользователей с правом `view_audit_log`) +- Фильтрация по пользователю, типу действия, типу сущности, дате +- Возможность просмотра diff (что изменилось) +- Восстановление удалённых данных из аудита + +#### 6.4 Настройки хранения +- Срок хранения истории настраивается администратором +- По умолчанию: 30 дней +- Автоматическая очистка старых записей по cron job + +### 7. Многопользовательская работа + +#### 7.1 Real-time обновления (WebSocket) +- Автоматическое обновление данных у всех пользователей при изменениях +- События: создание/редактирование/удаление идей, новые комментарии, изменение порядка +- Визуальная индикация изменений другими пользователями + +#### 7.2 Конкурентное редактирование +- При попытке редактировать идею, которую редактирует другой пользователь — предупреждение +- Показ кто сейчас редактирует запись +- Оптимистичная блокировка с version/updatedAt + +#### 7.3 Присутствие пользователей +- Показ онлайн пользователей +- Аватары/иконки пользователей, работающих с приложением + +### 8. Темная тема + +#### 8.1 Переключение темы +- Переключатель светлая/тёмная тема в header +- Автоопределение системной темы (prefers-color-scheme) +- Сохранение выбора в localStorage + +#### 8.2 Цветовая схема +- Все компоненты поддерживают обе темы +- Цвета статусов, приоритетов и маркировки адаптированы для тёмной темы +- MUI theme provider с dark mode + --- ## Технические требования @@ -108,7 +223,10 @@ - **Database**: PostgreSQL - **ORM**: TypeORM - **API**: REST + WebSocket (для real-time обновлений) +- **WebSocket**: @nestjs/websockets + Socket.io - **AI Integration**: ai-proxy service тут лежит гайд по интеграции /Users/vigdorov/dev/gptunnel-service/INTEGRATION.md +- **Document Generation**: docx (для экспорта) +- **Cron Jobs**: @nestjs/schedule (для очистки аудита) ### Frontend (React + TypeScript) @@ -137,25 +255,31 @@ ### Безопасность - Валидация входных данных - Rate limiting для AI-запросов +- Проверка прав доступа на каждом endpoint +- Защита от конкурентных изменений (оптимистичная блокировка) -### Авторизация +### Авторизация и авторизация - **Keycloak** (auth.vigdorov.ru) — внешний Identity Provider - Авторизация через редиректы на стандартную форму Keycloak - Authorization Code Flow + PKCE - JWT токены с валидацией через JWKS - Автоматическое обновление токенов - Защита всех API endpoints (кроме /health) -- Роли и права доступа НЕ требуются — просто аутентификация +- **Гранулярные права доступа** — см. раздел 5 +- **Администратор** определяется через K8s Secret `ADMIN_EMAIL` --- -## Открытые вопросы +## Решённые вопросы 1. Нужна ли многопользовательская работа и разграничение прав? -НЕТ +**ДА** — см. разделы 5 (Права доступа) и 7 (Многопользовательская работа) + 2. Требуется ли история изменений (audit log)? -НЕТ -4. Нужен ли экспорт данных (CSV, Excel)? -НЕТ -5. Интеграция с внешними системами (Jira, Trello)? -НЕТ +**ДА** — см. раздел 6 (История действий) + +3. Нужен ли экспорт данных? +**ДА** — экспорт отдельной идеи в DOCX (см. раздел 1.6) + +4. Интеграция с внешними системами (Jira, Trello)? +**НЕТ** — не требуется diff --git a/ROADMAP.md b/ROADMAP.md index 2badb17..664ae94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,13 @@ | 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование | | 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API | | 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда | -| 3 | AI-интеграция | ⏸️ Ожидает | Оценка времени, рекомендации | +| 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации | +| 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ | +| 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа | +| 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление | +| 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа | +| 7 | Темная тема | 📋 Планируется | Переключение светлая/тёмная | +| 8 | Экспорт | 📋 Планируется | Экспорт идеи в DOCX | --- @@ -161,37 +167,271 @@ --- -## Фаза 3: AI-интеграция ⏸️ +## Фаза 3: AI-интеграция ✅ ### Backend — Модуль AI -- [ ] Интегрировать ai-proxy service -- [ ] POST /api/ai/estimate - - [ ] Получить идею и состав команды - - [ ] Сформировать промпт - - [ ] Отправить запрос в AI - - [ ] Распарсить ответ - - [ ] Сохранить оценку -- [ ] Rate limiting для AI-запросов +- [x] Интегрировать ai-proxy service +- [x] POST /api/ai/estimate + - [x] Получить идею и состав команды + - [x] Сформировать промпт + - [x] Отправить запрос в AI + - [x] Распарсить ответ + - [x] Сохранить оценку +- [ ] Rate limiting для AI-запросов (опционально) ### Frontend — AI-оценка -- [ ] Кнопка "Оценить AI" в строке/детали идеи -- [ ] Модалка с результатом оценки - - [ ] Общее время - - [ ] Сложность - - [ ] Разбивка по ролям - - [ ] Рекомендации -- [ ] Отображение оценки в таблице -- [ ] Loading state для AI-запросов +- [x] Кнопка "Оценить AI" в строке/детали идеи +- [x] Модалка с результатом оценки + - [x] Общее время + - [x] Сложность + - [x] Разбивка по ролям + - [x] Рекомендации +- [x] Отображение оценки в таблице +- [x] Loading state для AI-запросов --- -## Backlog (после MVP) +## Фаза 3.1: Генерация мини-ТЗ ✅ + +> **Генерация технического задания с помощью AI + история версий** + +### Backend — Расширение модуля AI +- [x] Добавить поля в Idea entity (specification, specificationGeneratedAt) +- [x] Миграция для новых полей +- [x] POST /api/ai/generate-specification + - [x] Получить идею + - [x] Сформировать промпт для генерации ТЗ + - [x] Отправить запрос в AI + - [x] Сохранить результат +- [x] Обновить POST /api/ai/estimate — учитывать ТЗ в промпте +- [x] Добавить specification в UpdateIdeaDto + +### Backend — История ТЗ +- [x] SpecificationHistory entity +- [x] Миграция для specification_history таблицы +- [x] GET /api/ai/specification-history/:ideaId +- [x] DELETE /api/ai/specification-history/:historyId +- [x] POST /api/ai/specification-history/:historyId/restore +- [x] Автосохранение старого ТЗ в историю при перегенерации + +### Backend — Комментарии в AI-промптах +- [x] Включить комментарии к идее в промпт генерации ТЗ +- [x] Включить комментарии к идее в промпт оценки трудозатрат + +### Frontend — Модалка ТЗ +- [x] Новый компонент SpecificationModal + - [x] Режим генерации (loading → результат) + - [x] Режим просмотра + - [x] Режим редактирования + - [x] Markdown-рендеринг (react-markdown) +- [x] Кнопка ТЗ в колонке actions + - [x] Серая — ТЗ нет + - [x] Синяя — ТЗ есть + - [x] Spinner — генерация +- [x] Хук useGenerateSpecification +- [x] API метод generateSpecification + +### Frontend — История ТЗ +- [x] Табы "Текущее ТЗ" / "История" (при наличии истории) +- [x] Список исторических версий с датами +- [x] Просмотр исторической версии +- [x] Восстановление версии из истории +- [x] Удаление версии из истории +- [x] Хуки useSpecificationHistory, useDeleteSpecificationHistoryItem, useRestoreSpecificationFromHistory + +### E2E тестирование +- [x] Генерация ТЗ для идеи +- [x] Просмотр существующего ТЗ +- [x] Редактирование и сохранение ТЗ +- [x] data-testid для новых компонентов + +--- + +## Фаза 4: Права доступа 📋 + +> **Гранулярная система прав доступа и панель администратора** + +### Backend — Модуль Permissions +- [ ] User entity (userId, email, name, lastLogin) +- [ ] UserPermissions entity (связь с User, все права как boolean поля) +- [ ] Миграции для users и user_permissions +- [ ] PermissionsService (getMyPermissions, getUsersWithPermissions, updateUserPermissions) +- [ ] PermissionsController + - [ ] GET /api/permissions/me + - [ ] GET /api/permissions/users (admin only) + - [ ] PATCH /api/permissions/:userId (admin only) +- [ ] PermissionsGuard (проверка прав на endpoints) +- [ ] @RequirePermission() декоратор +- [ ] Env: ADMIN_EMAIL из K8s Secret +- [ ] Middleware: создание User при первом входе (только view_ideas) + +### Backend — Защита существующих endpoints +- [ ] IdeasController — проверка create_ideas, edit_own/any_ideas, delete_own/any_ideas +- [ ] CommentsController — проверка add_comments, delete_own/any_comments +- [ ] AiController — проверка request_ai_estimate, request_ai_specification +- [ ] TeamController — проверка manage_team, manage_roles + +### Frontend — Панель администратора +- [ ] AdminPage компонент +- [ ] PermissionsTable — таблица пользователей с чекбоксами прав +- [ ] usePermissions хуки (useMyPermissions, useUsersPermissions, useUpdatePermissions) +- [ ] Скрытие/отключение кнопок на основе прав +- [ ] Роутинг: /admin (только для админа) + +### Backend — Автор идеи +- [ ] Добавить поле authorId, authorName в Idea entity +- [ ] Миграция для новых полей +- [ ] Автозаполнение при создании идеи +- [ ] Запрет изменения автора в UpdateIdeaDto + +### Frontend — Отображение автора +- [ ] Колонка "Автор" в таблице идей +- [ ] Отображение автора в деталях идеи + +### E2E тестирование +- [ ] Тесты прав доступа +- [ ] Тесты панели администратора +- [ ] Тесты автора идеи + +--- + +## Фаза 5: Аудит и история 📋 + +> **Логирование всех действий с возможностью восстановления** + +### Backend — Модуль Audit +- [ ] AuditLog entity (userId, userName, action, entityType, entityId, oldValue, newValue, timestamp) +- [ ] Миграция для audit_log таблицы +- [ ] AuditService + - [ ] log(action, entityType, entityId, oldValue, newValue) + - [ ] getAuditLog(filters, pagination) + - [ ] restore(auditId) + - [ ] cleanup(olderThanDays) +- [ ] AuditController + - [ ] GET /api/audit + - [ ] POST /api/audit/:id/restore + - [ ] GET /api/audit/settings + - [ ] PATCH /api/audit/settings +- [ ] Интеграция AuditService во все сервисы (Ideas, Comments, Team, AI) +- [ ] Cron job для очистки старых записей (@nestjs/schedule) +- [ ] Env: AUDIT_RETENTION_DAYS + +### Frontend — Страница истории +- [ ] AuditPage компонент +- [ ] AuditLogTable с фильтрами +- [ ] AuditDetailModal (просмотр diff) +- [ ] Кнопка "Восстановить" для удалённых сущностей +- [ ] useAudit хуки + +### Frontend — Настройки аудита (в админ-панели) +- [ ] Поле "Срок хранения истории" в AdminPage +- [ ] useAuditSettings хук + +### E2E тестирование +- [ ] Тесты просмотра истории +- [ ] Тесты восстановления +- [ ] Тесты настроек аудита + +--- + +## Фаза 6: Real-time и WebSocket 📋 + +> **Многопользовательская работа с real-time обновлениями** + +### Backend — WebSocket Gateway +- [ ] Установить @nestjs/websockets, socket.io +- [ ] EventsGateway (handleConnection, handleDisconnect) +- [ ] JWT валидация в WebSocket handshake +- [ ] События: idea:created, idea:updated, idea:deleted, ideas:reordered +- [ ] События: comment:created, comment:deleted +- [ ] События: specification:generated, estimate:generated +- [ ] События присутствия: users:online, user:joined, user:left +- [ ] События редактирования: idea:editing, idea:stopEditing +- [ ] Интеграция emit во все сервисы + +### Frontend — WebSocket Provider +- [ ] WebSocketProvider компонент (socket.io-client) +- [ ] useWebSocket хук +- [ ] Автоматическая синхронизация React Query при получении событий +- [ ] Reconnect логика + +### Frontend — Индикаторы +- [ ] OnlineUsers компонент (список онлайн пользователей) +- [ ] EditingIndicator (кто редактирует идею) +- [ ] Визуальная подсветка изменённых строк + +### Frontend — Конкурентное редактирование +- [ ] Предупреждение при попытке редактировать занятую идею +- [ ] Optimistic locking (проверка version/updatedAt) +- [ ] Разрешение конфликтов + +### E2E тестирование +- [ ] Тесты real-time обновлений (2 браузера) +- [ ] Тесты присутствия +- [ ] Тесты конкурентного редактирования + +--- + +## Фаза 7: Темная тема 📋 + +> **Поддержка светлой и тёмной темы интерфейса** + +### Frontend — Theme Provider +- [ ] ThemeStore (Zustand) — текущая тема, автоопределение +- [ ] ThemeProvider (MUI createTheme с dark/light mode) +- [ ] Сохранение выбора в localStorage +- [ ] Автоопределение системной темы (prefers-color-scheme) + +### Frontend — Цветовые схемы +- [ ] Палитра для тёмной темы (см. ARCHITECTURE.md 5.1) +- [ ] Адаптация цветов статусов и приоритетов +- [ ] Адаптация цветов маркировки строк +- [ ] Адаптация всех компонентов + +### Frontend — UI +- [ ] ThemeToggle компонент в header +- [ ] Иконки ☀️/🌙 для переключения + +### E2E тестирование +- [ ] Тест переключения темы +- [ ] Визуальный тест тёмной темы + +--- + +## Фаза 8: Экспорт 📋 + +> **Экспорт идеи в документ DOCX** + +### Backend — Модуль Export +- [ ] Установить docx библиотеку +- [ ] ExportService + - [ ] generateIdeaDocx(ideaId) — генерация DOCX + - [ ] Включение: название, описание, статус, приоритет, модуль + - [ ] Включение: целевая аудитория, боль, роль AI, способ проверки + - [ ] Включение: AI-оценка (если есть) + - [ ] Включение: ТЗ в markdown → форматированный текст (если есть) + - [ ] Включение: комментарии (автор, дата, текст) +- [ ] ExportController + - [ ] GET /api/export/idea/:id + +### Frontend — Кнопка экспорта +- [ ] Кнопка экспорта в строке таблицы (⬇️ иконка) +- [ ] useExportIdea хук +- [ ] Скачивание файла через blob + +### E2E тестирование +- [ ] Тест экспорта идеи +- [ ] Проверка содержимого DOCX + +--- + +## Backlog (после фаз 4-8) -- [ ] WebSocket для real-time обновлений - [ ] Виртуализация списка (1000+ идей) - [ ] Keyboard shortcuts - [ ] Сохранение пресетов фильтров -- [ ] Темная тема +- [ ] Уведомления (email/push при упоминании) +- [ ] Интеграция с Jira/Trello (опционально) --- diff --git a/backend/src/ai/ai.controller.ts b/backend/src/ai/ai.controller.ts new file mode 100644 index 0000000..1e5e83a --- /dev/null +++ b/backend/src/ai/ai.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common'; +import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service'; +import { EstimateIdeaDto, GenerateSpecificationDto } from './dto'; + +@Controller('ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + + @Post('estimate') + async estimateIdea(@Body() dto: EstimateIdeaDto): Promise { + return this.aiService.estimateIdea(dto.ideaId); + } + + @Post('generate-specification') + async generateSpecification( + @Body() dto: GenerateSpecificationDto, + ): Promise { + return this.aiService.generateSpecification(dto.ideaId); + } + + @Get('specification-history/:ideaId') + async getSpecificationHistory( + @Param('ideaId') ideaId: string, + ): Promise { + return this.aiService.getSpecificationHistory(ideaId); + } + + @Delete('specification-history/:historyId') + async deleteSpecificationHistoryItem( + @Param('historyId') historyId: string, + ): Promise { + return this.aiService.deleteSpecificationHistoryItem(historyId); + } + + @Post('specification-history/:historyId/restore') + async restoreSpecificationFromHistory( + @Param('historyId') historyId: string, + ): Promise { + return this.aiService.restoreSpecificationFromHistory(historyId); + } +} diff --git a/backend/src/ai/ai.module.ts b/backend/src/ai/ai.module.ts new file mode 100644 index 0000000..98d886e --- /dev/null +++ b/backend/src/ai/ai.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; +import { Idea } from '../ideas/entities/idea.entity'; +import { TeamMember } from '../team/entities/team-member.entity'; +import { SpecificationHistory } from '../ideas/entities/specification-history.entity'; +import { Comment } from '../comments/entities/comment.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])], + controllers: [AiController], + providers: [AiService], + exports: [AiService], +}) +export class AiModule {} diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts new file mode 100644 index 0000000..43dd163 --- /dev/null +++ b/backend/src/ai/ai.service.ts @@ -0,0 +1,398 @@ +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Idea } from '../ideas/entities/idea.entity'; +import { TeamMember } from '../team/entities/team-member.entity'; +import { SpecificationHistory } from '../ideas/entities/specification-history.entity'; +import { Comment } from '../comments/entities/comment.entity'; + +export interface RoleEstimate { + role: string; + hours: number; +} + +export interface EstimateResult { + ideaId: string; + ideaTitle: string; + totalHours: number; + complexity: 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex'; + breakdown: RoleEstimate[]; + recommendations: string[]; + estimatedAt: Date; +} + +export interface SpecificationResult { + ideaId: string; + ideaTitle: string; + specification: string; + generatedAt: Date; +} + +export interface SpecificationHistoryItem { + id: string; + specification: string; + ideaDescriptionSnapshot: string | null; + createdAt: Date; +} + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + private readonly aiProxyBaseUrl: string; + private readonly aiProxyApiKey: string; + + constructor( + private configService: ConfigService, + @InjectRepository(Idea) + private ideaRepository: Repository, + @InjectRepository(TeamMember) + private teamMemberRepository: Repository, + @InjectRepository(SpecificationHistory) + private specificationHistoryRepository: Repository, + @InjectRepository(Comment) + private commentRepository: Repository, + ) { + this.aiProxyBaseUrl = this.configService.get( + 'AI_PROXY_BASE_URL', + 'http://ai-proxy-service.ai-proxy.svc.cluster.local:3000', + ); + this.aiProxyApiKey = this.configService.get('AI_PROXY_API_KEY', ''); + } + + async generateSpecification(ideaId: string): Promise { + // Загружаем идею + const idea = await this.ideaRepository.findOne({ where: { id: ideaId } }); + if (!idea) { + throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND); + } + + // Загружаем комментарии к идее + const comments = await this.commentRepository.find({ + where: { ideaId }, + order: { createdAt: 'ASC' }, + }); + + // Если уже есть ТЗ — сохраняем в историю + if (idea.specification) { + await this.specificationHistoryRepository.save({ + ideaId: idea.id, + specification: idea.specification, + ideaDescriptionSnapshot: idea.description, + }); + } + + // Формируем промпт для генерации ТЗ + const prompt = this.buildSpecificationPrompt(idea, comments); + + // Отправляем запрос к AI + const specification = await this.callAiProxy(prompt); + + // Сохраняем ТЗ в идею + const generatedAt = new Date(); + await this.ideaRepository.update(ideaId, { + specification, + specificationGeneratedAt: generatedAt, + }); + + return { + ideaId: idea.id, + ideaTitle: idea.title, + specification, + generatedAt, + }; + } + + async getSpecificationHistory(ideaId: string): Promise { + const history = await this.specificationHistoryRepository.find({ + where: { ideaId }, + order: { createdAt: 'DESC' }, + }); + + return history.map((item) => ({ + id: item.id, + specification: item.specification, + ideaDescriptionSnapshot: item.ideaDescriptionSnapshot, + createdAt: item.createdAt, + })); + } + + async deleteSpecificationHistoryItem(historyId: string): Promise { + const result = await this.specificationHistoryRepository.delete(historyId); + if (result.affected === 0) { + throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); + } + } + + async restoreSpecificationFromHistory(historyId: string): Promise { + const historyItem = await this.specificationHistoryRepository.findOne({ + where: { id: historyId }, + relations: ['idea'], + }); + + if (!historyItem) { + throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); + } + + const idea = historyItem.idea; + + // Сохраняем текущее ТЗ в историю (если есть) + if (idea.specification) { + await this.specificationHistoryRepository.save({ + ideaId: idea.id, + specification: idea.specification, + ideaDescriptionSnapshot: idea.description, + }); + } + + // Восстанавливаем ТЗ из истории + const generatedAt = new Date(); + await this.ideaRepository.update(idea.id, { + specification: historyItem.specification, + specificationGeneratedAt: generatedAt, + }); + + // Удаляем восстановленную запись из истории + await this.specificationHistoryRepository.delete(historyId); + + return { + ideaId: idea.id, + ideaTitle: idea.title, + specification: historyItem.specification, + generatedAt, + }; + } + + async estimateIdea(ideaId: string): Promise { + // Загружаем идею + const idea = await this.ideaRepository.findOne({ where: { id: ideaId } }); + if (!idea) { + throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND); + } + + // Загружаем комментарии к идее + const comments = await this.commentRepository.find({ + where: { ideaId }, + order: { createdAt: 'ASC' }, + }); + + // Загружаем состав команды + const teamMembers = await this.teamMemberRepository.find({ + relations: ['role'], + }); + + // Формируем промпт + const prompt = this.buildPrompt(idea, teamMembers, comments); + + // Отправляем запрос к AI + const aiResponse = await this.callAiProxy(prompt); + + // Парсим ответ + const result = this.parseAiResponse(aiResponse, idea); + + // Сохраняем оценку в идею + await this.ideaRepository.update(ideaId, { + estimatedHours: result.totalHours, + complexity: result.complexity, + estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations }, + estimatedAt: result.estimatedAt, + }); + + return result; + } + + private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string { + const teamInfo = teamMembers + .map((m) => { + const prod = m.productivity; + return `- ${m.name} (${m.role.name}): производительность — trivial: ${prod.trivial}ч, simple: ${prod.simple}ч, medium: ${prod.medium}ч, complex: ${prod.complex}ч, veryComplex: ${prod.veryComplex}ч`; + }) + .join('\n'); + + const rolesSummary = this.getRolesSummary(teamMembers); + + const commentsSection = comments.length > 0 + ? `## Комментарии к идее +${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} + +` + : ''; + + return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения. + +## Задача +Оцени трудозатраты на реализацию следующей идеи с учётом состава команды. + +## Идея +- **Название:** ${idea.title} +- **Описание:** ${idea.description || 'Не указано'} +- **Модуль:** ${idea.module || 'Не указан'} +- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'} +- **Боль/Проблема:** ${idea.pain || 'Не указана'} +- **Роль AI:** ${idea.aiRole || 'Не указана'} +- **Способ проверки:** ${idea.verificationMethod || 'Не указан'} +- **Приоритет:** ${idea.priority} + +## Техническое задание (ТЗ) +${idea.specification || 'Не указано'} + +${commentsSection}## Состав команды +${teamInfo || 'Команда не указана'} + +## Роли в команде +${rolesSummary} + +## Требуемый формат ответа (СТРОГО JSON) +Верни ТОЛЬКО JSON без markdown-разметки: +{ + "totalHours": <число — общее количество часов>, + "complexity": "<одно из: trivial, simple, medium, complex, veryComplex>", + "breakdown": [ + {"role": "<название роли>", "hours": <число>} + ], + "recommendations": ["<рекомендация 1>", "<рекомендация 2>"] +} + +Учитывай реальную производительность каждого члена команды при оценке. Обязательно учти информацию из комментариев — там могут быть важные уточнения и особенности.`; + } + + private getRolesSummary(teamMembers: TeamMember[]): string { + const rolesMap = new Map(); + for (const member of teamMembers) { + const roleName = member.role.name; + rolesMap.set(roleName, (rolesMap.get(roleName) || 0) + 1); + } + + return Array.from(rolesMap.entries()) + .map(([role, count]) => `- ${role}: ${count} чел.`) + .join('\n'); + } + + private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string { + const commentsSection = comments.length > 0 + ? `## Комментарии к идее +${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} + +` + : ''; + + return `Ты — опытный бизнес-аналитик и технический писатель. + +## Задача +Составь краткое техническое задание (мини-ТЗ) для следующей идеи. ТЗ должно быть достаточно детальным для оценки трудозатрат и понимания scope работ. + +## Идея +- **Название:** ${idea.title} +- **Описание:** ${idea.description || 'Не указано'} +- **Модуль:** ${idea.module || 'Не указан'} +- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'} +- **Боль/Проблема:** ${idea.pain || 'Не указана'} +- **Роль AI:** ${idea.aiRole || 'Не указана'} +- **Способ проверки:** ${idea.verificationMethod || 'Не указан'} +- **Приоритет:** ${idea.priority} + +${commentsSection}## Требования к ТЗ +Мини-ТЗ должно содержать: +1. **Цель** — что должно быть достигнуто +2. **Функциональные требования** — основные функции (3-7 пунктов) +3. **Нефункциональные требования** — если применимо (производительность, безопасность) +4. **Критерии приёмки** — как понять что задача выполнена +5. **Ограничения и допущения** — что не входит в scope + +**Важно:** Обязательно учти информацию из комментариев при составлении ТЗ — там могут быть важные уточнения, требования и особенности реализации. + +## Формат ответа +Напиши ТЗ в формате Markdown. Будь конкретен, избегай общих фраз. Объём: 200-400 слов.`; + } + + private async callAiProxy(prompt: string): Promise { + if (!this.aiProxyApiKey) { + throw new HttpException( + 'AI_PROXY_API_KEY не настроен', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + const response = await fetch( + `${this.aiProxyBaseUrl}/api/v1/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.aiProxyApiKey, + }, + body: JSON.stringify({ + model: 'claude-3.7-sonnet', + messages: [ + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.3, + max_tokens: 1000, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + this.logger.error(`AI Proxy error: ${response.status} - ${errorText}`); + throw new HttpException( + 'Ошибка при запросе к AI сервису', + HttpStatus.BAD_GATEWAY, + ); + } + + const data = await response.json(); + return data.choices[0].message.content; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + this.logger.error(`AI Proxy call failed: ${error.message}`); + throw new HttpException( + 'Не удалось подключиться к AI сервису', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + private parseAiResponse(aiResponse: string, idea: Idea): EstimateResult { + try { + // Удаляем возможную markdown-разметку + let cleanJson = aiResponse.trim(); + if (cleanJson.startsWith('```json')) { + cleanJson = cleanJson.slice(7); + } + if (cleanJson.startsWith('```')) { + cleanJson = cleanJson.slice(3); + } + if (cleanJson.endsWith('```')) { + cleanJson = cleanJson.slice(0, -3); + } + cleanJson = cleanJson.trim(); + + const parsed = JSON.parse(cleanJson); + + return { + ideaId: idea.id, + ideaTitle: idea.title, + totalHours: Number(parsed.totalHours) || 0, + complexity: parsed.complexity || 'medium', + breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [], + recommendations: Array.isArray(parsed.recommendations) + ? parsed.recommendations + : [], + estimatedAt: new Date(), + }; + } catch (error) { + this.logger.error(`Failed to parse AI response: ${aiResponse}`); + throw new HttpException( + 'Не удалось разобрать ответ AI', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/backend/src/ai/dto/estimate-idea.dto.ts b/backend/src/ai/dto/estimate-idea.dto.ts new file mode 100644 index 0000000..828805e --- /dev/null +++ b/backend/src/ai/dto/estimate-idea.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class EstimateIdeaDto { + @IsUUID() + ideaId: string; +} diff --git a/backend/src/ai/dto/generate-specification.dto.ts b/backend/src/ai/dto/generate-specification.dto.ts new file mode 100644 index 0000000..2257f21 --- /dev/null +++ b/backend/src/ai/dto/generate-specification.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class GenerateSpecificationDto { + @IsUUID() + ideaId: string; +} diff --git a/backend/src/ai/dto/index.ts b/backend/src/ai/dto/index.ts new file mode 100644 index 0000000..d4441f7 --- /dev/null +++ b/backend/src/ai/dto/index.ts @@ -0,0 +1,2 @@ +export * from './estimate-idea.dto'; +export * from './generate-specification.dto'; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 08ead23..0a261b7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { IdeasModule } from './ideas/ideas.module'; import { CommentsModule } from './comments/comments.module'; import { TeamModule } from './team/team.module'; import { AuthModule, JwtAuthGuard } from './auth'; +import { AiModule } from './ai/ai.module'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { AuthModule, JwtAuthGuard } from './auth'; IdeasModule, CommentsModule, TeamModule, + AiModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/ideas/dto/update-idea.dto.ts b/backend/src/ideas/dto/update-idea.dto.ts index d6d0853..018c636 100644 --- a/backend/src/ideas/dto/update-idea.dto.ts +++ b/backend/src/ideas/dto/update-idea.dto.ts @@ -1,5 +1,5 @@ import { PartialType } from '@nestjs/mapped-types'; -import { IsOptional, IsInt, Min } from 'class-validator'; +import { IsOptional, IsInt, Min, IsString } from 'class-validator'; import { CreateIdeaDto } from './create-idea.dto'; export class UpdateIdeaDto extends PartialType(CreateIdeaDto) { @@ -7,4 +7,8 @@ export class UpdateIdeaDto extends PartialType(CreateIdeaDto) { @IsInt() @Min(0) order?: number; + + @IsOptional() + @IsString() + specification?: string; } diff --git a/backend/src/ideas/entities/idea.entity.ts b/backend/src/ideas/entities/idea.entity.ts index a726d5a..dac4710 100644 --- a/backend/src/ideas/entities/idea.entity.ts +++ b/backend/src/ideas/entities/idea.entity.ts @@ -72,6 +72,26 @@ export class Idea { @Column({ type: 'int', default: 0 }) order: number; + // AI-оценка + @Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true }) + estimatedHours: number | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + complexity: string | null; + + @Column({ name: 'estimate_details', type: 'jsonb', nullable: true }) + estimateDetails: Record | null; + + @Column({ name: 'estimated_at', type: 'timestamp', nullable: true }) + estimatedAt: Date | null; + + // Мини-ТЗ + @Column({ type: 'text', nullable: true }) + specification: string | null; + + @Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true }) + specificationGeneratedAt: Date | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/backend/src/ideas/entities/specification-history.entity.ts b/backend/src/ideas/entities/specification-history.entity.ts new file mode 100644 index 0000000..f47f459 --- /dev/null +++ b/backend/src/ideas/entities/specification-history.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Idea } from './idea.entity'; + +@Entity('specification_history') +export class SpecificationHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'idea_id' }) + ideaId: string; + + @ManyToOne(() => Idea, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'idea_id' }) + idea: Idea; + + @Column({ type: 'text' }) + specification: string; + + @Column({ name: 'idea_description_snapshot', type: 'text', nullable: true }) + ideaDescriptionSnapshot: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/ideas/ideas.module.ts b/backend/src/ideas/ideas.module.ts index 5978bfd..3b381bf 100644 --- a/backend/src/ideas/ideas.module.ts +++ b/backend/src/ideas/ideas.module.ts @@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { IdeasService } from './ideas.service'; import { IdeasController } from './ideas.controller'; import { Idea } from './entities/idea.entity'; +import { SpecificationHistory } from './entities/specification-history.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Idea])], + imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])], controllers: [IdeasController], providers: [IdeasService], - exports: [IdeasService], + exports: [IdeasService, TypeOrmModule], }) export class IdeasModule {} diff --git a/backend/src/migrations/1736899500000-AddAiEstimateFields.ts b/backend/src/migrations/1736899500000-AddAiEstimateFields.ts new file mode 100644 index 0000000..5c8008d --- /dev/null +++ b/backend/src/migrations/1736899500000-AddAiEstimateFields.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAiEstimateFields1736899500000 implements MigrationInterface { + name = 'AddAiEstimateFields1736899500000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ideas" + ADD COLUMN "estimated_hours" DECIMAL(10, 2), + ADD COLUMN "complexity" VARCHAR(20), + ADD COLUMN "estimate_details" JSONB, + ADD COLUMN "estimated_at" TIMESTAMP + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ideas" + DROP COLUMN "estimated_at", + DROP COLUMN "estimate_details", + DROP COLUMN "complexity", + DROP COLUMN "estimated_hours" + `); + } +} diff --git a/backend/src/migrations/1736942400000-AddSpecificationField.ts b/backend/src/migrations/1736942400000-AddSpecificationField.ts new file mode 100644 index 0000000..4d37539 --- /dev/null +++ b/backend/src/migrations/1736942400000-AddSpecificationField.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSpecificationField1736942400000 implements MigrationInterface { + name = 'AddSpecificationField1736942400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ideas" + ADD COLUMN "specification" TEXT, + ADD COLUMN "specification_generated_at" TIMESTAMP + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ideas" + DROP COLUMN "specification_generated_at", + DROP COLUMN "specification" + `); + } +} diff --git a/backend/src/migrations/1736943000000-AddSpecificationHistory.ts b/backend/src/migrations/1736943000000-AddSpecificationHistory.ts new file mode 100644 index 0000000..b8a59c0 --- /dev/null +++ b/backend/src/migrations/1736943000000-AddSpecificationHistory.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSpecificationHistory1736943000000 implements MigrationInterface { + name = 'AddSpecificationHistory1736943000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "specification_history" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "idea_id" uuid NOT NULL, + "specification" text NOT NULL, + "idea_description_snapshot" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_specification_history" PRIMARY KEY ("id"), + CONSTRAINT "FK_specification_history_idea" FOREIGN KEY ("idea_id") + REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_specification_history_idea_id" ON "specification_history" ("idea_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_specification_history_idea_id"`); + await queryRunner.query(`DROP TABLE "specification_history"`); + } +} diff --git a/frontend/package.json b/frontend/package.json index d914621..00510ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "keycloak-js": "^26.2.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx b/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx new file mode 100644 index 0000000..c7f1221 --- /dev/null +++ b/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx @@ -0,0 +1,204 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Chip, + LinearProgress, + Alert, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Paper, + List, + ListItem, + ListItemIcon, + ListItemText, +} from '@mui/material'; +import { + AccessTime, + TrendingUp, + Lightbulb, + CheckCircle, +} from '@mui/icons-material'; +import type { EstimateResult } from '../../services/ai'; +import type { IdeaComplexity } from '../../types/idea'; + +interface AiEstimateModalProps { + open: boolean; + onClose: () => void; + result: EstimateResult | null; + isLoading: boolean; + error: Error | null; +} + +const complexityLabels: Record = { + trivial: 'Тривиальная', + simple: 'Простая', + medium: 'Средняя', + complex: 'Сложная', + veryComplex: 'Очень сложная', +}; + +const complexityColors: Record< + IdeaComplexity, + 'success' | 'info' | 'warning' | 'error' | 'default' +> = { + trivial: 'success', + simple: 'success', + medium: 'info', + complex: 'warning', + veryComplex: 'error', +}; + +function formatHours(hours: number): string { + if (hours < 8) { + return `${hours} ч`; + } + const days = Math.floor(hours / 8); + const remainingHours = hours % 8; + if (remainingHours === 0) { + return `${days} д`; + } + return `${days} д ${remainingHours} ч`; +} + +export function AiEstimateModal({ + open, + onClose, + result, + isLoading, + error, +}: AiEstimateModalProps) { + return ( + + + AI-оценка трудозатрат + {result && ( + + {result.ideaTitle} + + )} + + + {isLoading && ( + + + Анализируем идею и состав команды... + + + + )} + + {error && ( + + {error.message || 'Не удалось получить оценку'} + + )} + + {result && !isLoading && ( + + {/* Общая оценка */} + + + + + + {formatHours(result.totalHours)} + + + + Общее время + + + + + + + + + Сложность + + + + + {/* Разбивка по ролям */} + {result.breakdown.length > 0 && ( + + + Разбивка по ролям + + + + + + Роль + Время + + + + {result.breakdown.map((item, index) => ( + + {item.role} + {formatHours(item.hours)} + + ))} + +
+
+
+ )} + + {/* Рекомендации */} + {result.recommendations.length > 0 && ( + + + + Рекомендации + + + {result.recommendations.map((rec, index) => ( + + + + + + + ))} + + + )} +
+ )} +
+ + + +
+ ); +} diff --git a/frontend/src/components/AiEstimateModal/index.ts b/frontend/src/components/AiEstimateModal/index.ts new file mode 100644 index 0000000..6c84602 --- /dev/null +++ b/frontend/src/components/AiEstimateModal/index.ts @@ -0,0 +1 @@ +export { AiEstimateModal } from './AiEstimateModal'; diff --git a/frontend/src/components/IdeasTable/IdeasTable.tsx b/frontend/src/components/IdeasTable/IdeasTable.tsx index 791bcc3..954b67b 100644 --- a/frontend/src/components/IdeasTable/IdeasTable.tsx +++ b/frontend/src/components/IdeasTable/IdeasTable.tsx @@ -40,18 +40,35 @@ import { useIdeasQuery, useDeleteIdea, useReorderIdeas, + useUpdateIdea, } from '../../hooks/useIdeas'; +import { + useEstimateIdea, + useGenerateSpecification, + useSpecificationHistory, + useDeleteSpecificationHistoryItem, + useRestoreSpecificationFromHistory, +} from '../../hooks/useAi'; import { useIdeasStore } from '../../store/ideas'; import { createColumns } from './columns'; import { DraggableRow } from './DraggableRow'; import { CommentsPanel } from '../CommentsPanel'; +import { AiEstimateModal } from '../AiEstimateModal'; +import { SpecificationModal } from '../SpecificationModal'; +import type { EstimateResult } from '../../services/ai'; +import type { Idea } from '../../types/idea'; -const SKELETON_COLUMNS_COUNT = 9; +const SKELETON_COLUMNS_COUNT = 10; export function IdeasTable() { const { data, isLoading, isError } = useIdeasQuery(); const deleteIdea = useDeleteIdea(); const reorderIdeas = useReorderIdeas(); + const updateIdea = useUpdateIdea(); + const estimateIdea = useEstimateIdea(); + const generateSpecification = useGenerateSpecification(); + const deleteSpecificationHistoryItem = useDeleteSpecificationHistoryItem(); + const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory(); const { sorting, setSorting, pagination, setPage, setLimit } = useIdeasStore(); @@ -59,19 +76,140 @@ export function IdeasTable() { const [activeId, setActiveId] = useState(null); // ID идеи с раскрытыми комментариями const [expandedId, setExpandedId] = useState(null); + // AI-оценка + const [estimatingId, setEstimatingId] = useState(null); + const [estimateModalOpen, setEstimateModalOpen] = useState(false); + const [estimateResult, setEstimateResult] = useState(null); + // ТЗ (спецификация) + const [specificationModalOpen, setSpecificationModalOpen] = useState(false); + const [specificationIdea, setSpecificationIdea] = useState(null); + const [generatedSpecification, setGeneratedSpecification] = useState(null); + const [generatingSpecificationId, setGeneratingSpecificationId] = useState(null); + + // История ТЗ + const specificationHistory = useSpecificationHistory(specificationIdea?.id ?? null); const handleToggleComments = (id: string) => { setExpandedId((prev) => (prev === id ? null : id)); }; + const handleEstimate = (id: string) => { + setEstimatingId(id); + setEstimateModalOpen(true); + setEstimateResult(null); + estimateIdea.mutate(id, { + onSuccess: (result) => { + setEstimateResult(result); + setEstimatingId(null); + }, + onError: () => { + setEstimatingId(null); + }, + }); + }; + + const handleCloseEstimateModal = () => { + setEstimateModalOpen(false); + setEstimateResult(null); + }; + + const handleViewEstimate = (idea: Idea) => { + if (!idea.estimatedHours || !idea.estimateDetails) return; + + // Показываем сохранённые результаты оценки + setEstimateResult({ + ideaId: idea.id, + ideaTitle: idea.title, + totalHours: idea.estimatedHours, + complexity: idea.complexity!, + breakdown: idea.estimateDetails.breakdown, + recommendations: idea.estimateDetails.recommendations, + estimatedAt: idea.estimatedAt!, + }); + setEstimateModalOpen(true); + }; + + const handleSpecification = (idea: Idea) => { + setSpecificationIdea(idea); + setSpecificationModalOpen(true); + + // Если ТЗ уже есть — показываем его + if (idea.specification) { + setGeneratedSpecification(idea.specification); + return; + } + + // Иначе генерируем + setGeneratedSpecification(null); + setGeneratingSpecificationId(idea.id); + generateSpecification.mutate(idea.id, { + onSuccess: (result) => { + setGeneratedSpecification(result.specification); + setGeneratingSpecificationId(null); + }, + onError: () => { + setGeneratingSpecificationId(null); + }, + }); + }; + + const handleCloseSpecificationModal = () => { + setSpecificationModalOpen(false); + setSpecificationIdea(null); + setGeneratedSpecification(null); + }; + + const handleSaveSpecification = (specification: string) => { + if (!specificationIdea) return; + updateIdea.mutate( + { id: specificationIdea.id, data: { specification } }, + { + onSuccess: () => { + setGeneratedSpecification(specification); + }, + }, + ); + }; + + const handleRegenerateSpecification = () => { + if (!specificationIdea) return; + setGeneratingSpecificationId(specificationIdea.id); + generateSpecification.mutate(specificationIdea.id, { + onSuccess: (result) => { + setGeneratedSpecification(result.specification); + setGeneratingSpecificationId(null); + }, + onError: () => { + setGeneratingSpecificationId(null); + }, + }); + }; + + const handleDeleteHistoryItem = (historyId: string) => { + deleteSpecificationHistoryItem.mutate(historyId); + }; + + const handleRestoreFromHistory = (historyId: string) => { + restoreSpecificationFromHistory.mutate(historyId, { + onSuccess: (result) => { + setGeneratedSpecification(result.specification); + }, + }); + }; + const columns = useMemo( () => createColumns({ onDelete: (id) => deleteIdea.mutate(id), onToggleComments: handleToggleComments, + onEstimate: handleEstimate, + onViewEstimate: handleViewEstimate, + onSpecification: handleSpecification, expandedId, + estimatingId, + generatingSpecificationId, }), - [deleteIdea, expandedId], + [deleteIdea, expandedId, estimatingId, generatingSpecificationId], ); // eslint-disable-next-line react-hooks/incompatible-library @@ -307,6 +445,29 @@ export function IdeasTable() { rowsPerPageOptions={[10, 20, 50, 100]} /> )} + + ); } diff --git a/frontend/src/components/IdeasTable/columns.tsx b/frontend/src/components/IdeasTable/columns.tsx index 005e2d6..91e0020 100644 --- a/frontend/src/components/IdeasTable/columns.tsx +++ b/frontend/src/components/IdeasTable/columns.tsx @@ -1,7 +1,7 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { Chip, Box, IconButton, Tooltip } from '@mui/material'; -import { Delete, Comment, ExpandLess } from '@mui/icons-material'; -import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea'; +import { Chip, Box, IconButton, Tooltip, CircularProgress, Typography } from '@mui/material'; +import { Delete, Comment, ExpandLess, AutoAwesome, AccessTime, Description } from '@mui/icons-material'; +import type { Idea, IdeaStatus, IdeaPriority, IdeaComplexity } from '../../types/idea'; import { EditableCell } from './EditableCell'; import { ColorPickerCell } from './ColorPickerCell'; import { statusOptions, priorityOptions } from './constants'; @@ -30,13 +30,45 @@ const priorityColors: Record< critical: 'error', }; +const complexityLabels: Record = { + trivial: 'Триви.', + simple: 'Прост.', + medium: 'Сред.', + complex: 'Сложн.', + veryComplex: 'Оч.сложн.', +}; + +const complexityColors: Record< + IdeaComplexity, + 'success' | 'info' | 'warning' | 'error' | 'default' +> = { + trivial: 'success', + simple: 'success', + medium: 'info', + complex: 'warning', + veryComplex: 'error', +}; + +function formatHoursShort(hours: number): string { + if (hours < 8) { + return `${hours}ч`; + } + const days = Math.floor(hours / 8); + return `${days}д`; +} + interface ColumnsConfig { onDelete: (id: string) => void; onToggleComments: (id: string) => void; + onEstimate: (id: string) => void; + onViewEstimate: (idea: Idea) => void; + onSpecification: (idea: Idea) => void; expandedId: string | null; + estimatingId: string | null; + generatingSpecificationId: string | null; } -export const createColumns = ({ onDelete, onToggleComments, expandedId }: ColumnsConfig) => [ +export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEstimate, onSpecification, expandedId, estimatingId, generatingSpecificationId }: ColumnsConfig) => [ columnHelper.display({ id: 'drag', header: '', @@ -153,14 +185,103 @@ export const createColumns = ({ onDelete, onToggleComments, expandedId }: Column }, size: 200, }), + columnHelper.accessor('estimatedHours', { + header: 'Оценка', + cell: (info) => { + const idea = info.row.original; + if (!idea.estimatedHours) { + return ( + + — + + ); + } + return ( + + onViewEstimate(idea)} + data-testid="view-estimate-button" + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + cursor: 'pointer', + borderRadius: 1, + px: 0.5, + py: 0.25, + mx: -0.5, + '&:hover': { + backgroundColor: 'action.hover', + }, + }} + > + + + {formatHoursShort(idea.estimatedHours)} + + {idea.complexity && ( + + )} + + + ); + }, + size: 130, + enableSorting: false, + }), columnHelper.display({ id: 'actions', header: '', cell: (info) => { - const ideaId = info.row.original.id; + const idea = info.row.original; + const ideaId = idea.id; const isExpanded = expandedId === ideaId; + const isEstimating = estimatingId === ideaId; + const isGeneratingSpec = generatingSpecificationId === ideaId; + const hasSpecification = !!idea.specification; return ( + + + onSpecification(idea)} + disabled={isGeneratingSpec} + color={hasSpecification ? 'primary' : 'default'} + data-testid="specification-button" + sx={{ opacity: hasSpecification ? 0.9 : 0.5, '&:hover': { opacity: 1 } }} + > + {isGeneratingSpec ? ( + + ) : ( + + )} + + + + + + onEstimate(ideaId)} + disabled={isEstimating} + color="primary" + data-testid="estimate-idea-button" + sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }} + > + {isEstimating ? ( + + ) : ( + + )} + + + ); }, - size: 90, + size: 150, }), ]; diff --git a/frontend/src/components/SpecificationModal/SpecificationModal.tsx b/frontend/src/components/SpecificationModal/SpecificationModal.tsx new file mode 100644 index 0000000..b0e6250 --- /dev/null +++ b/frontend/src/components/SpecificationModal/SpecificationModal.tsx @@ -0,0 +1,464 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + LinearProgress, + Alert, + TextField, + IconButton, + Tooltip, + Tabs, + Tab, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Divider, + Chip, +} from '@mui/material'; +import { + Edit, + Save, + Close, + Refresh, + Delete, + Restore, + Visibility, + History, +} from '@mui/icons-material'; +import Markdown from 'react-markdown'; +import type { Idea, SpecificationHistoryItem } from '../../types/idea'; + +interface SpecificationModalProps { + open: boolean; + onClose: () => void; + idea: Idea | null; + specification: string | null; + isLoading: boolean; + error: Error | null; + onSave: (specification: string) => void; + isSaving: boolean; + onRegenerate: () => void; + history: SpecificationHistoryItem[]; + isHistoryLoading: boolean; + onDeleteHistoryItem: (historyId: string) => void; + onRestoreFromHistory: (historyId: string) => void; + isRestoring: boolean; +} + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel({ children, value, index }: TabPanelProps) { + return ( + + ); +} + +export function SpecificationModal({ + open, + onClose, + idea, + specification, + isLoading, + error, + onSave, + isSaving, + onRegenerate, + history, + isHistoryLoading, + onDeleteHistoryItem, + onRestoreFromHistory, + isRestoring, +}: SpecificationModalProps) { + const [isEditing, setIsEditing] = useState(false); + const [editedText, setEditedText] = useState(''); + const [tabIndex, setTabIndex] = useState(0); + const [viewingHistoryItem, setViewingHistoryItem] = useState(null); + + // Сбрасываем состояние при открытии/закрытии + useEffect(() => { + if (open && specification) { + setEditedText(specification); + setIsEditing(false); + setTabIndex(0); + setViewingHistoryItem(null); + } + }, [open, specification]); + + const handleEdit = () => { + setEditedText(specification || ''); + setIsEditing(true); + }; + + const handleCancel = () => { + setEditedText(specification || ''); + setIsEditing(false); + }; + + const handleSave = () => { + onSave(editedText); + setIsEditing(false); + }; + + const handleRegenerate = () => { + setViewingHistoryItem(null); + setTabIndex(0); + onRegenerate(); + }; + + const handleViewHistoryItem = (item: SpecificationHistoryItem) => { + setViewingHistoryItem(item); + }; + + const handleCloseHistoryView = () => { + setViewingHistoryItem(null); + }; + + const handleRestoreFromHistory = (historyId: string) => { + onRestoreFromHistory(historyId); + setViewingHistoryItem(null); + setTabIndex(0); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return ''; + return new Date(dateString).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const hasHistory = history.length > 0; + + return ( + + + + + Техническое задание + + {idea && ( + + {idea.title} + + )} + + + {specification && !isLoading && !isEditing && !viewingHistoryItem && ( + <> + + + + + + + + + + + + )} + + + + {/* Табы появляются только если есть история */} + {hasHistory && !isEditing && !viewingHistoryItem && ( + setTabIndex(newValue)} + sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }} + > + + + + История ({history.length}) + + } + data-testid="specification-tab-history" + /> + + )} + + + {/* Просмотр исторического ТЗ */} + {viewingHistoryItem && ( + + + + + + + Версия от {formatDate(viewingHistoryItem.createdAt)} + + + handleRestoreFromHistory(viewingHistoryItem.id)} + disabled={isRestoring} + data-testid="specification-restore-button" + > + + + + + {viewingHistoryItem.ideaDescriptionSnapshot && ( + + + Описание идеи на момент генерации: {viewingHistoryItem.ideaDescriptionSnapshot} + + + )} + + {viewingHistoryItem.specification} + + + )} + + {/* Основной контент (не историческая версия) */} + {!viewingHistoryItem && ( + <> + + {isLoading && ( + + + Генерируем техническое задание... + + + + )} + + {error && ( + + {error.message || 'Не удалось сгенерировать ТЗ'} + + )} + + {!isLoading && !error && isEditing && ( + setEditedText(e.target.value)} + placeholder="Введите техническое задание..." + data-testid="specification-textarea" + sx={{ + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.875rem', + }, + }} + /> + )} + + {!isLoading && !error && !isEditing && specification && ( + + {idea?.specificationGeneratedAt && ( + + Сгенерировано: {formatDate(idea.specificationGeneratedAt)} + + )} + + {specification} + + + )} + + + + {isHistoryLoading ? ( + + + + ) : history.length === 0 ? ( + + История пуста + + ) : ( + + {history.map((item, index) => ( + + {index > 0 && } + + + + {formatDate(item.createdAt)} + + {item.ideaDescriptionSnapshot && ( + + )} + + } + secondary={ + + {item.specification.slice(0, 150)}... + + } + /> + + + handleViewHistoryItem(item)} + data-testid={`specification-history-view-${index}`} + > + + + + + handleRestoreFromHistory(item.id)} + disabled={isRestoring} + data-testid={`specification-history-restore-${index}`} + > + + + + + onDeleteHistoryItem(item.id)} + data-testid={`specification-history-delete-${index}`} + > + + + + + + + ))} + + )} + + + )} + + + + {isEditing ? ( + <> + + + + ) : viewingHistoryItem ? ( + + ) : ( + + )} + + + ); +} diff --git a/frontend/src/components/SpecificationModal/index.ts b/frontend/src/components/SpecificationModal/index.ts new file mode 100644 index 0000000..113ba12 --- /dev/null +++ b/frontend/src/components/SpecificationModal/index.ts @@ -0,0 +1 @@ +export * from './SpecificationModal'; diff --git a/frontend/src/hooks/useAi.ts b/frontend/src/hooks/useAi.ts new file mode 100644 index 0000000..f092d59 --- /dev/null +++ b/frontend/src/hooks/useAi.ts @@ -0,0 +1,63 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { aiApi } from '../services/ai'; + +const IDEAS_QUERY_KEY = 'ideas'; +const SPECIFICATION_HISTORY_KEY = 'specification-history'; + +export function useEstimateIdea() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ideaId: string) => aiApi.estimateIdea(ideaId), + onSuccess: () => { + // Инвалидируем кэш идей чтобы обновить данные с новой оценкой + void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); + }, + }); +} + +export function useGenerateSpecification() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ideaId: string) => aiApi.generateSpecification(ideaId), + onSuccess: (_, ideaId) => { + // Инвалидируем кэш идей и историю + void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); + void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY, ideaId] }); + }, + }); +} + +export function useSpecificationHistory(ideaId: string | null) { + return useQuery({ + queryKey: [SPECIFICATION_HISTORY_KEY, ideaId], + queryFn: () => aiApi.getSpecificationHistory(ideaId!), + enabled: !!ideaId, + }); +} + +export function useDeleteSpecificationHistoryItem() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId), + onSuccess: () => { + // Инвалидируем все запросы истории + void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); + }, + }); +} + +export function useRestoreSpecificationFromHistory() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId), + onSuccess: () => { + // Инвалидируем кэш идей и историю + void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); + void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); + }, + }); +} diff --git a/frontend/src/services/ai.ts b/frontend/src/services/ai.ts new file mode 100644 index 0000000..879e889 --- /dev/null +++ b/frontend/src/services/ai.ts @@ -0,0 +1,38 @@ +import { api } from './api'; +import type { IdeaComplexity, RoleEstimate, SpecificationResult, SpecificationHistoryItem } from '../types/idea'; + +export interface EstimateResult { + ideaId: string; + ideaTitle: string; + totalHours: number; + complexity: IdeaComplexity; + breakdown: RoleEstimate[]; + recommendations: string[]; + estimatedAt: string; +} + +export const aiApi = { + estimateIdea: async (ideaId: string): Promise => { + const { data } = await api.post('/ai/estimate', { ideaId }); + return data; + }, + + generateSpecification: async (ideaId: string): Promise => { + const { data } = await api.post('/ai/generate-specification', { ideaId }); + return data; + }, + + getSpecificationHistory: async (ideaId: string): Promise => { + const { data } = await api.get(`/ai/specification-history/${ideaId}`); + return data; + }, + + deleteSpecificationHistoryItem: async (historyId: string): Promise => { + await api.delete(`/ai/specification-history/${historyId}`); + }, + + restoreSpecificationFromHistory: async (historyId: string): Promise => { + const { data } = await api.post(`/ai/specification-history/${historyId}/restore`); + return data; + }, +}; diff --git a/frontend/src/types/idea.ts b/frontend/src/types/idea.ts index 8e3a85d..03da2ec 100644 --- a/frontend/src/types/idea.ts +++ b/frontend/src/types/idea.ts @@ -6,6 +6,18 @@ export type IdeaStatus = | 'cancelled'; export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical'; +export type IdeaComplexity = 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex'; + +export interface RoleEstimate { + role: string; + hours: number; +} + +export interface EstimateDetails { + breakdown: RoleEstimate[]; + recommendations: string[]; +} + export interface Idea { id: string; title: string; @@ -19,6 +31,14 @@ export interface Idea { verificationMethod: string | null; color: string | null; order: number; + // AI-оценка + estimatedHours: number | null; + complexity: IdeaComplexity | null; + estimateDetails: EstimateDetails | null; + estimatedAt: string | null; + // Мини-ТЗ + specification: string | null; + specificationGeneratedAt: string | null; createdAt: string; updatedAt: string; } @@ -39,4 +59,19 @@ export interface CreateIdeaDto { export interface UpdateIdeaDto extends Omit, 'color'> { order?: number; color?: string | null; + specification?: string; +} + +export interface SpecificationResult { + ideaId: string; + ideaTitle: string; + specification: string; + generatedAt: string; +} + +export interface SpecificationHistoryItem { + id: string; + specification: string; + ideaDescriptionSnapshot: string | null; + createdAt: string; } diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index ffe1599..675bc71 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -45,6 +45,13 @@ spec: key: db-password - name: KEYCLOAK_REALM_URL value: "https://auth.vigdorov.ru/realms/team-planner" + - name: AI_PROXY_BASE_URL + value: "http://ai-proxy-service.ai-proxy.svc.cluster.local:3000" + - name: AI_PROXY_API_KEY + valueFrom: + secretKeyRef: + name: team-planner-secrets + key: ai-proxy-api-key resources: requests: memory: "256Mi" diff --git a/package-lock.json b/package-lock.json index 98a19b3..c5e6cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2330,11 +2330,6 @@ "@types/estree": "*" } }, - "backend/node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, "backend/node_modules/@types/express": { "version": "5.0.6", "dev": true, @@ -2681,11 +2676,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "backend/node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "dev": true, - "license": "ISC" - }, "backend/node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "cpu": [ @@ -8414,6 +8404,7 @@ "keycloak-js": "^26.2.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -9369,11 +9360,6 @@ "@babel/types": "^7.28.2" } }, - "frontend/node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, "frontend/node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -9395,13 +9381,6 @@ "version": "15.7.15", "license": "MIT" }, - "frontend/node_modules/@types/react": { - "version": "19.2.7", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, "frontend/node_modules/@types/react-dom": { "version": "19.2.3", "dev": true, @@ -9894,10 +9873,6 @@ "node": ">= 8" } }, - "frontend/node_modules/csstype": { - "version": "3.2.3", - "license": "MIT" - }, "frontend/node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -11494,6 +11469,30 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -11518,6 +11517,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -11534,6 +11542,15 @@ "@types/node": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -11599,6 +11616,15 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -11629,12 +11655,24 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -11663,12 +11701,32 @@ "resolved": "backend", "link": true }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11699,6 +11757,46 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", @@ -11748,6 +11846,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -11773,6 +11881,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -11790,6 +11904,41 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -11814,6 +11963,22 @@ "node": ">=6" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/file-type": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz", @@ -11855,6 +12020,56 @@ "node": ">=8" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11875,6 +12090,46 @@ ], "license": "BSD-3-Clause" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -11884,6 +12139,28 @@ "node": ">=8" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -12049,6 +12326,16 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -12071,12 +12358,632 @@ "lru-cache": "6.0.0" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/passport": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", @@ -12134,6 +13041,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -12155,12 +13072,72 @@ "react": "^19.2.3" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12230,6 +13207,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -12244,6 +13231,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -12272,6 +13273,24 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -12316,6 +13335,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -12352,6 +13391,93 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12370,6 +13496,34 @@ "node": ">= 0.10" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -12428,6 +13582,16 @@ "engines": { "node": ">=12" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/tests/e2e/phase3.spec.ts b/tests/e2e/phase3.spec.ts new file mode 100644 index 0000000..18612c1 --- /dev/null +++ b/tests/e2e/phase3.spec.ts @@ -0,0 +1,463 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E тесты для Фазы 3 Team Planner + * - AI-оценка трудозатрат + * + * Используем data-testid для стабильных селекторов + */ + +test.describe('Фаза 3: AI-оценка', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 }); + }); + + test('Кнопка AI-оценки присутствует в каждой строке', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + if (hasData) { + const estimateButtons = page.locator('[data-testid="estimate-idea-button"]'); + const buttonCount = await estimateButtons.count(); + expect(buttonCount).toBeGreaterThan(0); + } + }); + + test('Клик на кнопку AI-оценки открывает модалку', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Кликаем на кнопку AI-оценки первой идеи + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + // Проверяем что модалка открылась + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + }); + + test('Модалка AI-оценки показывает загрузку', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Должен быть либо индикатор загрузки, либо результат, либо ошибка + const hasContent = await modal.locator('text=Анализируем').isVisible().catch(() => false) || + await modal.locator('text=Общее время').isVisible().catch(() => false) || + await modal.locator('text=Не удалось').isVisible().catch(() => false); + + expect(hasContent).toBeTruthy(); + }); + + test('AI-оценка возвращает результат с часами и сложностью', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём результат (до 30 секунд - AI может отвечать долго) + const totalTimeLabel = modal.locator('text=Общее время'); + await expect(totalTimeLabel).toBeVisible({ timeout: 30000 }); + + // Проверяем наличие сложности + const complexityLabel = modal.locator('text=Сложность'); + await expect(complexityLabel).toBeVisible(); + }); + + test('AI-оценка показывает разбивку по ролям', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём результат + await expect(modal.locator('text=Общее время')).toBeVisible({ timeout: 30000 }); + + // Проверяем наличие таблицы разбивки по ролям + const breakdownLabel = modal.locator('text=Разбивка по ролям'); + // Разбивка опциональна (может не быть если команда не указана) + const hasBreakdown = await breakdownLabel.isVisible().catch(() => false); + + if (hasBreakdown) { + const breakdownRows = modal.locator('[data-testid^="estimate-breakdown-row-"]'); + const rowCount = await breakdownRows.count(); + expect(rowCount).toBeGreaterThanOrEqual(0); + } + }); + + test('Кнопка закрытия модалки работает', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Закрываем модалку + const closeButton = page.locator('[data-testid="close-estimate-modal-button"]'); + await closeButton.click(); + + // Модалка должна закрыться + await expect(modal).not.toBeVisible({ timeout: 3000 }); + }); + + test('После оценки результат сохраняется в строке таблицы', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Запоминаем первую строку + const firstRow = page.locator('[data-testid^="idea-row-"]').first(); + + // Кликаем на оценку + const estimateButton = firstRow.locator('[data-testid="estimate-idea-button"]'); + await estimateButton.click(); + + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём результат + await expect(modal.locator('text=Общее время')).toBeVisible({ timeout: 30000 }); + + // Закрываем модалку + await page.locator('[data-testid="close-estimate-modal-button"]').click(); + await expect(modal).not.toBeVisible({ timeout: 3000 }); + + // Проверяем что в строке появилась оценка (часы или дни) + // Ищем текст типа "8ч" или "2д" в строке + await page.waitForTimeout(500); + + // Колонка "Оценка" должна содержать данные + const rowText = await firstRow.textContent(); + const hasEstimate = rowText?.match(/\d+[чд]/) !== null; + + expect(hasEstimate).toBeTruthy(); + }); + + test('Колонка "Оценка" отображается в таблице', async ({ page }) => { + const table = page.locator('[data-testid="ideas-table"]'); + await expect(table).toBeVisible(); + + // Проверяем наличие заголовка колонки + const header = table.locator('th', { hasText: 'Оценка' }); + await expect(header).toBeVisible(); + }); + + test('Клик по оценке открывает модалку с деталями', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Ищем строку с оценкой (кнопка view-estimate-button появляется только если есть оценка) + const viewEstimateButton = page.locator('[data-testid="view-estimate-button"]').first(); + const hasEstimate = await viewEstimateButton.isVisible().catch(() => false); + + test.skip(!hasEstimate, 'Нет идей с оценкой для тестирования'); + + // Кликаем по оценке + await viewEstimateButton.click(); + + // Модалка должна открыться с деталями + const modal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Должны быть видны результаты (без загрузки) + await expect(modal.locator('text=Общее время')).toBeVisible(); + await expect(modal.locator('text=Сложность')).toBeVisible(); + }); +}); + +test.describe('Фаза 3: AI-оценка - создание данных для теста', () => { + test('Создание идеи и запуск AI-оценки', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 }); + + // Проверяем есть ли кнопка создания идеи + const createButton = page.locator('[data-testid="create-idea-button"]'); + const hasCreateButton = await createButton.isVisible().catch(() => false); + + if (hasCreateButton) { + // Создаём идею + await createButton.click(); + + const modal = page.locator('[data-testid="create-idea-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Заполняем форму + await page.locator('[data-testid="idea-title-input"] input').fill('Тестовая идея для AI-оценки'); + await page.locator('[data-testid="idea-description-input"] textarea').first().fill( + 'Реализовать систему уведомлений. Нужны email и push-уведомления для важных событий.' + ); + + // Сохраняем + await page.locator('[data-testid="submit-create-idea"]').click(); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + + // Ждём появления новой строки + await page.waitForTimeout(1000); + } + + // Теперь проверяем AI-оценку + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Не удалось создать данные для тестирования'); + + // Запускаем AI-оценку + const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first(); + await estimateButton.click(); + + const estimateModal = page.locator('[data-testid="ai-estimate-modal"]'); + await expect(estimateModal).toBeVisible({ timeout: 5000 }); + + // Ждём результат (до 60 секунд - AI может отвечать долго) + // Или ошибку (текст "Не удалось" из компонента) + const resultOrError = estimateModal.locator('text=/Общее время|Не удалось/'); + await expect(resultOrError).toBeVisible({ timeout: 60000 }); + }); +}); + +/** + * Тесты для генерации мини-ТЗ (Phase 3.1) + */ +test.describe('Фаза 3.1: Генерация мини-ТЗ', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 }); + }); + + test('Кнопка ТЗ присутствует в каждой строке', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + if (hasData) { + const specButtons = page.locator('[data-testid="specification-button"]'); + const buttonCount = await specButtons.count(); + expect(buttonCount).toBeGreaterThan(0); + } + }); + + test('Клик на кнопку ТЗ открывает модалку', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Кликаем на кнопку ТЗ первой идеи + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + // Проверяем что модалка открылась + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + }); + + test('Модалка ТЗ показывает загрузку при генерации', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Ищем строку без ТЗ (кнопка не подсвечена синим) + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Должен быть либо индикатор загрузки, либо контент, либо ошибка + const hasContent = await modal.locator('[data-testid="specification-loading"]').isVisible().catch(() => false) || + await modal.locator('[data-testid="specification-content"]').isVisible().catch(() => false) || + await modal.locator('[data-testid="specification-error"]').isVisible().catch(() => false); + + expect(hasContent).toBeTruthy(); + }); + + test('Генерация ТЗ возвращает результат', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём результат (до 60 секунд - AI может отвечать долго) + const content = modal.locator('[data-testid="specification-content"]'); + const error = modal.locator('[data-testid="specification-error"]'); + + // Ожидаем либо контент, либо ошибку + await expect(content.or(error)).toBeVisible({ timeout: 60000 }); + }); + + test('Кнопка закрытия модалки ТЗ работает', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём пока загрузится контент или ошибка + const content = modal.locator('[data-testid="specification-content"]'); + const error = modal.locator('[data-testid="specification-error"]'); + await expect(content.or(error)).toBeVisible({ timeout: 60000 }); + + // Закрываем модалку + const closeButton = page.locator('[data-testid="specification-close-button"]'); + await closeButton.click(); + + // Модалка должна закрыться + await expect(modal).not.toBeVisible({ timeout: 3000 }); + }); + + test('Кнопка редактирования ТЗ появляется после генерации', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём контент + const content = modal.locator('[data-testid="specification-content"]'); + const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false); + + if (hasContent) { + // Проверяем наличие кнопки редактирования + const editButton = modal.locator('[data-testid="specification-edit-button"]'); + await expect(editButton).toBeVisible(); + } + }); + + test('Редактирование ТЗ открывает textarea', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём контент + const content = modal.locator('[data-testid="specification-content"]'); + const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false); + + test.skip(!hasContent, 'Не удалось сгенерировать ТЗ'); + + // Кликаем редактировать + const editButton = modal.locator('[data-testid="specification-edit-button"]'); + await editButton.click(); + + // Должен появиться textarea + const textarea = modal.locator('[data-testid="specification-textarea"]'); + await expect(textarea).toBeVisible(); + }); + + test('Сохранение отредактированного ТЗ', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + const specButton = page.locator('[data-testid="specification-button"]').first(); + await specButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Ждём контент + const content = modal.locator('[data-testid="specification-content"]'); + const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false); + + test.skip(!hasContent, 'Не удалось сгенерировать ТЗ'); + + // Кликаем редактировать + const editButton = modal.locator('[data-testid="specification-edit-button"]'); + await editButton.click(); + + // Редактируем текст + const textarea = modal.locator('[data-testid="specification-textarea"] textarea'); + const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now(); + await textarea.fill(await textarea.inputValue() + testText); + + // Сохраняем + const saveButton = modal.locator('[data-testid="specification-save-button"]'); + await saveButton.click(); + + // Должен вернуться режим просмотра + await expect(content).toBeVisible({ timeout: 5000 }); + + // Проверяем что изменения сохранились + const contentText = await content.textContent(); + expect(contentText).toContain('Дополнительно'); + }); + + test('Повторное открытие показывает сохранённое ТЗ', async ({ page }) => { + const emptyState = page.locator('[data-testid="ideas-empty-state"]'); + const hasData = !(await emptyState.isVisible().catch(() => false)); + + test.skip(!hasData, 'Нет данных для тестирования'); + + // Ищем идею с уже сгенерированным ТЗ (кнопка синяя) + const blueSpecButton = page.locator('[data-testid="specification-button"][class*="primary"]').first(); + const hasExistingSpec = await blueSpecButton.isVisible().catch(() => false); + + test.skip(!hasExistingSpec, 'Нет идей с ТЗ для тестирования'); + + await blueSpecButton.click(); + + const modal = page.locator('[data-testid="specification-modal"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Контент должен появиться сразу (без загрузки) + const content = modal.locator('[data-testid="specification-content"]'); + await expect(content).toBeVisible({ timeout: 3000 }); + + // Не должно быть индикатора загрузки + const loading = modal.locator('[data-testid="specification-loading"]'); + await expect(loading).not.toBeVisible(); + }); +}); diff --git a/tests/playwright/.auth/user.json b/tests/playwright/.auth/user.json index 8337d1e..68c44ee 100644 --- a/tests/playwright/.auth/user.json +++ b/tests/playwright/.auth/user.json @@ -2,7 +2,7 @@ "cookies": [ { "name": "AUTH_SESSION_ID", - "value": "aDcxV2VydUZQUVNSUHM0S290YzZtdVV2LlJBd2xHOXUyNWh6a1o2Qkc0V3pxRkpkNng5MVkza2o2REE0eTYyN21jWTJ6TS1WbC01Yk16UWZjZFRHcFNjWDRpMWJNTlhQZUZkZ3MxeW9WcHd4dnBn.keycloak-keycloakx-0-40655", + "value": "c2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllLnNBM2ZQTk5yRlBKek5lS3FoR093OFloU1ZyU3E1QzFadzVIU1Jta2lMRllqbXJxLW9QSEMxOFkzZWZDZDl3UHVKZUVaU0VvWWJTOVRNTHJJSUpZc1hB.keycloak-keycloakx-0-40655", "domain": "auth.vigdorov.ru", "path": "/realms/team-planner/", "expires": -1, @@ -12,17 +12,17 @@ }, { "name": "KC_AUTH_SESSION_HASH", - "value": "on0q6coyrWw3ypD0a99QFRAKTjOKY9lwC5JUXEZd+1M", + "value": "\"gFqhBG3DVcCfpsSCaidKwK+Ziy23r6ddJ/rdb/jKDs8\"", "domain": "auth.vigdorov.ru", "path": "/realms/team-planner/", - "expires": 1768425176.234387, + "expires": 1768427781.187379, "httpOnly": false, "secure": true, "sameSite": "None" }, { "name": "KEYCLOAK_IDENTITY", - "value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjExMTcsImlhdCI6MTc2ODQyNTExNywianRpIjoiMjMxZmU5ZmQtM2QzMC1hODE4LWJiZTItNjhjMDRhMTNlMTk1IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoiaDcxV2VydUZQUVNSUHM0S290YzZtdVV2Iiwic3RhdGVfY2hlY2tlciI6IjZjSXJIcFBVX09FSnpkNUpWWHRPMUVveS1aaVN1RS1jVGNpQVRyX01WVWsifQ.B4IGHS3mMLHkLMJlfyU8xJK_Xz8wtTeOEtSm57qbKHdnUYdXaavWNdPwIZ1rrPprPiypqn0_Ddj28dQVdNkClQ", + "value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjM3MjMsImlhdCI6MTc2ODQyNzcyMywianRpIjoiNGRmN2U5MzQtY2Q4Mi1hYTYwLTViNTUtMWFhZjVlMWViODJjIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoic2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllIiwic3RhdGVfY2hlY2tlciI6Im9Ic2R0czlWR0RvV19EcjcxbG4tM2FjWDR1SmJuMWtzdHRCcVpzRnlPbDQifQ.Nbi8YdiZddWqY4rsS7b_hin9cbTedp2bOQ11I25tLdTH6VGGJaCP1T59pYd3OlqyDYPoD97uOBiobKTues1rwg", "domain": "auth.vigdorov.ru", "path": "/realms/team-planner/", "expires": -1, @@ -32,10 +32,10 @@ }, { "name": "KEYCLOAK_SESSION", - "value": "on0q6coyrWw3ypD0a99QFRAKTjOKY9lwC5JUXEZd-1M", + "value": "gFqhBG3DVcCfpsSCaidKwK-Ziy23r6ddJ_rdb_jKDs8", "domain": "auth.vigdorov.ru", "path": "/realms/team-planner/", - "expires": 1768461118.031888, + "expires": 1768463723.271756, "httpOnly": false, "secure": true, "sameSite": "None"