feat(scenario): add scenario and scenario step CRUD module

- add ScenarioEntity and ScenarioStepEntity with cascade delete
- step fields: type (login|exec), sessionName (required), execCode, validateCode
- login steps create a named session; exec steps consume it by sessionName
- full CRUD controller under /scenarios and /scenarios/:id/steps
- paginated GET /scenarios with page/limit query params returning { data, total, page, limit }
- register ScenarioModule and entities in AppModule
This commit is contained in:
2026-04-07 14:13:06 +03:00
parent 5ac26ecb3a
commit d669bf1c59
11 changed files with 396 additions and 1 deletions
+5 -1
View File
@@ -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<string>('DB_PATH', 'data/sessions.db'),
entities: [SessionEntity, EnvironmentEntity],
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity],
synchronize: true,
}),
}),
AuthModule,
BrowserModule,
EnvironmentModule,
ScenarioModule,
McpModule,
],
controllers: [HealthController],
@@ -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;
}
+9
View File
@@ -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;
}
+19
View File
@@ -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;
}
@@ -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;
}
+10
View File
@@ -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;
}
+45
View File
@@ -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;
}
+114
View File
@@ -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);
}
}
+30
View File
@@ -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;
}
+14
View File
@@ -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 {}
+96
View File
@@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
}
@Injectable()
export class ScenarioService {
constructor(
@InjectRepository(ScenarioEntity)
private readonly scenarioRepo: Repository<ScenarioEntity>,
@InjectRepository(ScenarioStepEntity)
private readonly stepRepo: Repository<ScenarioStepEntity>,
) {}
// ── Scenarios ─────────────────────────────────────────────────────────────
create(dto: CreateScenarioDto): Promise<ScenarioEntity> {
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
}
async findAll(query: PaginationQueryDto): Promise<PaginatedResult<ScenarioEntity>> {
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<ScenarioEntity> {
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<ScenarioEntity> {
const scenario = await this.findOne(id);
Object.assign(scenario, dto);
return this.scenarioRepo.save(scenario);
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.scenarioRepo.delete(id);
}
// ── Steps ─────────────────────────────────────────────────────────────────
async createStep(scenarioId: number, dto: CreateScenarioStepDto): Promise<ScenarioStepEntity> {
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<ScenarioStepEntity> {
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<ScenarioStepEntity> {
const step = await this.findStep(scenarioId, stepId);
Object.assign(step, dto);
return this.stepRepo.save(step);
}
async removeStep(scenarioId: number, stepId: number): Promise<void> {
await this.findStep(scenarioId, stepId);
await this.stepRepo.delete(stepId);
}
}