diff --git a/src/app.module.ts b/src/app.module.ts index 5a514f1..4648eb3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,9 @@ import { SessionEntity } from './session/session.entity'; import { EnvironmentEntity } from './environment/environment.entity'; import { EnvironmentModule } from './environment/environment.module'; import { McpModule } from './mcp/mcp.module'; +import { ScenarioEntity } from './scenario/scenario.entity'; +import { ScenarioStepEntity } from './scenario/scenario-step.entity'; +import { ScenarioModule } from './scenario/scenario.module'; @Module({ imports: [ @@ -20,13 +23,14 @@ import { McpModule } from './mcp/mcp.module'; useFactory: (config: ConfigService) => ({ type: 'better-sqlite3', database: config.get('DB_PATH', 'data/sessions.db'), - entities: [SessionEntity, EnvironmentEntity], + entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity], synchronize: true, }), }), AuthModule, BrowserModule, EnvironmentModule, + ScenarioModule, McpModule, ], controllers: [HealthController], diff --git a/src/scenario/dto/create-scenario-step.dto.ts b/src/scenario/dto/create-scenario-step.dto.ts new file mode 100644 index 0000000..621898f --- /dev/null +++ b/src/scenario/dto/create-scenario-step.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { StepType } from '../scenario-step.entity'; + +export class CreateScenarioStepDto { + @ApiProperty({ enum: ['login', 'exec'], example: 'exec' }) + @IsIn(['login', 'exec']) + type: StepType; + + @ApiProperty({ description: 'Session name used by this step. login steps create it; exec steps consume it.', example: 'my-session' }) + @IsString() + @IsNotEmpty() + sessionName: string; + + @ApiPropertyOptional({ example: 'return await page.title();' }) + @IsOptional() + @IsString() + @IsNotEmpty() + execCode?: string; + + @ApiPropertyOptional({ example: 'return { success: result !== null, description: "title present" };' }) + @IsOptional() + @IsString() + @IsNotEmpty() + validateCode?: string; +} diff --git a/src/scenario/dto/create-scenario.dto.ts b/src/scenario/dto/create-scenario.dto.ts new file mode 100644 index 0000000..0d3b8f2 --- /dev/null +++ b/src/scenario/dto/create-scenario.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateScenarioDto { + @ApiProperty({ example: 'Login and verify cabinet' }) + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/scenario/dto/pagination-query.dto.ts b/src/scenario/dto/pagination-query.dto.ts new file mode 100644 index 0000000..1b8b501 --- /dev/null +++ b/src/scenario/dto/pagination-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ example: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ example: 20, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/src/scenario/dto/update-scenario-step.dto.ts b/src/scenario/dto/update-scenario-step.dto.ts new file mode 100644 index 0000000..323a4cb --- /dev/null +++ b/src/scenario/dto/update-scenario-step.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { StepType } from '../scenario-step.entity'; + +export class UpdateScenarioStepDto { + @ApiPropertyOptional({ enum: ['login', 'exec'] }) + @IsOptional() + @IsIn(['login', 'exec']) + type?: StepType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + sessionName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + execCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + validateCode?: string; +} diff --git a/src/scenario/dto/update-scenario.dto.ts b/src/scenario/dto/update-scenario.dto.ts new file mode 100644 index 0000000..f4d5dbf --- /dev/null +++ b/src/scenario/dto/update-scenario.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsNotEmpty } from 'class-validator'; + +export class UpdateScenarioDto { + @ApiPropertyOptional({ example: 'Updated scenario name' }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; +} diff --git a/src/scenario/scenario-step.entity.ts b/src/scenario/scenario-step.entity.ts new file mode 100644 index 0000000..9d8ca6b --- /dev/null +++ b/src/scenario/scenario-step.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ScenarioEntity } from './scenario.entity'; + +export type StepType = 'login' | 'exec'; + +@Entity('scenario_steps') +export class ScenarioStepEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + scenarioId: number; + + @ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'scenarioId' }) + scenario: ScenarioEntity; + + @Column({ type: 'text' }) + type: StepType; + + @Column({ type: 'text' }) + sessionName: string; + + @Column({ type: 'text', nullable: true }) + execCode: string | null; + + @Column({ type: 'text', nullable: true }) + validateCode: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/scenario/scenario.controller.ts b/src/scenario/scenario.controller.ts new file mode 100644 index 0000000..21465e8 --- /dev/null +++ b/src/scenario/scenario.controller.ts @@ -0,0 +1,114 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ScenarioService } from './scenario.service'; +import { CreateScenarioDto } from './dto/create-scenario.dto'; +import { UpdateScenarioDto } from './dto/update-scenario.dto'; +import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; +import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; + +@ApiTags('scenarios') +@Controller('scenarios') +export class ScenarioController { + constructor(private readonly scenarioService: ScenarioService) {} + + // ── Scenarios ───────────────────────────────────────────────────────────── + + @Post() + @ApiOperation({ summary: 'Create a scenario' }) + @ApiResponse({ status: 201, description: 'Scenario created' }) + create(@Body() dto: CreateScenarioDto) { + return this.scenarioService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all scenarios (paginated)' }) + @ApiResponse({ status: 200 }) + findAll(@Query() query: PaginationQueryDto) { + return this.scenarioService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a scenario with its steps' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: 'Scenario not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.scenarioService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a scenario' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: 'Scenario not found' }) + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateScenarioDto) { + return this.scenarioService.update(id, dto); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a scenario and all its steps' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 404, description: 'Scenario not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.scenarioService.remove(id); + } + + // ── Steps ───────────────────────────────────────────────────────────────── + + @Post(':id/steps') + @ApiOperation({ summary: 'Add a step to a scenario' }) + @ApiResponse({ status: 201, description: 'Step created' }) + @ApiResponse({ status: 404, description: 'Scenario not found' }) + createStep( + @Param('id', ParseIntPipe) id: number, + @Body() dto: CreateScenarioStepDto, + ) { + return this.scenarioService.createStep(id, dto); + } + + @Get(':id/steps/:stepId') + @ApiOperation({ summary: 'Get a single step' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + findStep( + @Param('id', ParseIntPipe) id: number, + @Param('stepId', ParseIntPipe) stepId: number, + ) { + return this.scenarioService.findStep(id, stepId); + } + + @Patch(':id/steps/:stepId') + @ApiOperation({ summary: 'Update a step' }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + updateStep( + @Param('id', ParseIntPipe) id: number, + @Param('stepId', ParseIntPipe) stepId: number, + @Body() dto: UpdateScenarioStepDto, + ) { + return this.scenarioService.updateStep(id, stepId, dto); + } + + @Delete(':id/steps/:stepId') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a step' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + removeStep( + @Param('id', ParseIntPipe) id: number, + @Param('stepId', ParseIntPipe) stepId: number, + ) { + return this.scenarioService.removeStep(id, stepId); + } +} diff --git a/src/scenario/scenario.entity.ts b/src/scenario/scenario.entity.ts new file mode 100644 index 0000000..84defbe --- /dev/null +++ b/src/scenario/scenario.entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { ScenarioStepEntity } from './scenario-step.entity'; + +@Entity('scenarios') +export class ScenarioEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @OneToMany(() => ScenarioStepEntity, (step) => step.scenario, { + cascade: true, + eager: false, + }) + steps: ScenarioStepEntity[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/scenario/scenario.module.ts b/src/scenario/scenario.module.ts new file mode 100644 index 0000000..b208e9c --- /dev/null +++ b/src/scenario/scenario.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScenarioEntity } from './scenario.entity'; +import { ScenarioStepEntity } from './scenario-step.entity'; +import { ScenarioService } from './scenario.service'; +import { ScenarioController } from './scenario.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity])], + controllers: [ScenarioController], + providers: [ScenarioService], + exports: [ScenarioService], +}) +export class ScenarioModule {} diff --git a/src/scenario/scenario.service.ts b/src/scenario/scenario.service.ts new file mode 100644 index 0000000..7e58759 --- /dev/null +++ b/src/scenario/scenario.service.ts @@ -0,0 +1,96 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ScenarioEntity } from './scenario.entity'; +import { ScenarioStepEntity } from './scenario-step.entity'; +import { CreateScenarioDto } from './dto/create-scenario.dto'; +import { UpdateScenarioDto } from './dto/update-scenario.dto'; +import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; +import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; +} + +@Injectable() +export class ScenarioService { + constructor( + @InjectRepository(ScenarioEntity) + private readonly scenarioRepo: Repository, + @InjectRepository(ScenarioStepEntity) + private readonly stepRepo: Repository, + ) {} + + // ── Scenarios ───────────────────────────────────────────────────────────── + + create(dto: CreateScenarioDto): Promise { + return this.scenarioRepo.save(this.scenarioRepo.create(dto)); + } + + async findAll(query: PaginationQueryDto): Promise> { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const [data, total] = await this.scenarioRepo.findAndCount({ + order: { id: 'ASC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: number): Promise { + const scenario = await this.scenarioRepo.findOne({ + where: { id }, + relations: ['steps'], + order: { steps: { id: 'ASC' } }, + }); + if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); + return scenario; + } + + async update(id: number, dto: UpdateScenarioDto): Promise { + const scenario = await this.findOne(id); + Object.assign(scenario, dto); + return this.scenarioRepo.save(scenario); + } + + async remove(id: number): Promise { + await this.findOne(id); + await this.scenarioRepo.delete(id); + } + + // ── Steps ───────────────────────────────────────────────────────────────── + + async createStep(scenarioId: number, dto: CreateScenarioStepDto): Promise { + await this.findOne(scenarioId); + return this.stepRepo.save( + this.stepRepo.create({ + ...dto, + scenarioId, + execCode: dto.execCode ?? null, + validateCode: dto.validateCode ?? null, + }), + ); + } + + async findStep(scenarioId: number, stepId: number): Promise { + const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId }); + if (!step) throw new NotFoundException(`Step ${stepId} not found in scenario ${scenarioId}`); + return step; + } + + async updateStep(scenarioId: number, stepId: number, dto: UpdateScenarioStepDto): Promise { + const step = await this.findStep(scenarioId, stepId); + Object.assign(step, dto); + return this.stepRepo.save(step); + } + + async removeStep(scenarioId: number, stepId: number): Promise { + await this.findStep(scenarioId, stepId); + await this.stepRepo.delete(stepId); + } +}