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:
+5
-1
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user