add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
This commit is contained in:
41
backend/src/ai/ai.controller.ts
Normal file
41
backend/src/ai/ai.controller.ts
Normal file
@ -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<EstimateResult> {
|
||||
return this.aiService.estimateIdea(dto.ideaId);
|
||||
}
|
||||
|
||||
@Post('generate-specification')
|
||||
async generateSpecification(
|
||||
@Body() dto: GenerateSpecificationDto,
|
||||
): Promise<SpecificationResult> {
|
||||
return this.aiService.generateSpecification(dto.ideaId);
|
||||
}
|
||||
|
||||
@Get('specification-history/:ideaId')
|
||||
async getSpecificationHistory(
|
||||
@Param('ideaId') ideaId: string,
|
||||
): Promise<SpecificationHistoryItem[]> {
|
||||
return this.aiService.getSpecificationHistory(ideaId);
|
||||
}
|
||||
|
||||
@Delete('specification-history/:historyId')
|
||||
async deleteSpecificationHistoryItem(
|
||||
@Param('historyId') historyId: string,
|
||||
): Promise<void> {
|
||||
return this.aiService.deleteSpecificationHistoryItem(historyId);
|
||||
}
|
||||
|
||||
@Post('specification-history/:historyId/restore')
|
||||
async restoreSpecificationFromHistory(
|
||||
@Param('historyId') historyId: string,
|
||||
): Promise<SpecificationResult> {
|
||||
return this.aiService.restoreSpecificationFromHistory(historyId);
|
||||
}
|
||||
}
|
||||
16
backend/src/ai/ai.module.ts
Normal file
16
backend/src/ai/ai.module.ts
Normal file
@ -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 {}
|
||||
398
backend/src/ai/ai.service.ts
Normal file
398
backend/src/ai/ai.service.ts
Normal file
@ -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<Idea>,
|
||||
@InjectRepository(TeamMember)
|
||||
private teamMemberRepository: Repository<TeamMember>,
|
||||
@InjectRepository(SpecificationHistory)
|
||||
private specificationHistoryRepository: Repository<SpecificationHistory>,
|
||||
@InjectRepository(Comment)
|
||||
private commentRepository: Repository<Comment>,
|
||||
) {
|
||||
this.aiProxyBaseUrl = this.configService.get<string>(
|
||||
'AI_PROXY_BASE_URL',
|
||||
'http://ai-proxy-service.ai-proxy.svc.cluster.local:3000',
|
||||
);
|
||||
this.aiProxyApiKey = this.configService.get<string>('AI_PROXY_API_KEY', '');
|
||||
}
|
||||
|
||||
async generateSpecification(ideaId: string): Promise<SpecificationResult> {
|
||||
// Загружаем идею
|
||||
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<SpecificationHistoryItem[]> {
|
||||
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<void> {
|
||||
const result = await this.specificationHistoryRepository.delete(historyId);
|
||||
if (result.affected === 0) {
|
||||
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> {
|
||||
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<EstimateResult> {
|
||||
// Загружаем идею
|
||||
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<string, number>();
|
||||
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<string> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class EstimateIdeaDto {
|
||||
@IsUUID()
|
||||
ideaId: string;
|
||||
}
|
||||
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class GenerateSpecificationDto {
|
||||
@IsUUID()
|
||||
ideaId: string;
|
||||
}
|
||||
2
backend/src/ai/dto/index.ts
Normal file
2
backend/src/ai/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './estimate-idea.dto';
|
||||
export * from './generate-specification.dto';
|
||||
@ -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: [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<string, unknown> | 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;
|
||||
|
||||
|
||||
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAiEstimateFields1736899500000 implements MigrationInterface {
|
||||
name = 'AddAiEstimateFields1736899500000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
DROP COLUMN "estimated_at",
|
||||
DROP COLUMN "estimate_details",
|
||||
DROP COLUMN "complexity",
|
||||
DROP COLUMN "estimated_hours"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSpecificationField1736942400000 implements MigrationInterface {
|
||||
name = 'AddSpecificationField1736942400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
ADD COLUMN "specification" TEXT,
|
||||
ADD COLUMN "specification_generated_at" TIMESTAMP
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
DROP COLUMN "specification_generated_at",
|
||||
DROP COLUMN "specification"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSpecificationHistory1736943000000 implements MigrationInterface {
|
||||
name = 'AddSpecificationHistory1736943000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_specification_history_idea_id"`);
|
||||
await queryRunner.query(`DROP TABLE "specification_history"`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user