feat(scenarios): add step title, scenario credentials, and environment context in executor

- add nullable title column to scenario steps; exposed in create/edit forms and step table
- add scenario-credential join (many-to-many with CredentialEntity) with CRUD endpoints
- expose environment URLs in code executor helpers via helpers.env and helpers.getEnvUrl()
- resolve and cache environment per run from the login step's environmentName in scheduler
- add section spacing and step table title column to ScenarioDetailPage
This commit is contained in:
2026-04-09 23:37:21 +03:00
parent 1f3a604940
commit 1efbbb38a3
34 changed files with 1362 additions and 14 deletions
+87 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ScenarioEntity } from "./scenario.entity";
@@ -6,10 +6,13 @@ import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
import { CredentialEntity } from "../credential/credential.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 { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
import {
PaginationQueryDto,
PaginatedResult,
@@ -33,6 +36,10 @@ export class ScenarioService {
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
@InjectRepository(ScenarioCredentialEntity)
private readonly scenarioCredRepo: Repository<ScenarioCredentialEntity>,
@InjectRepository(CredentialEntity)
private readonly credentialRepo: Repository<CredentialEntity>,
) {}
// ── Scenarios ─────────────────────────────────────────────────────────────
@@ -59,7 +66,7 @@ export class ScenarioService {
async findOne(id: number): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({
where: { id },
relations: ["steps"],
relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"],
order: { steps: { order: "ASC" } },
});
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
@@ -121,6 +128,84 @@ export class ScenarioService {
await this.stepRepo.delete(stepId);
}
// ── Scenario Credentials ──────────────────────────────────────────────────
async findScenarioCredentials(
scenarioId: number,
): Promise<ScenarioCredentialEntity[]> {
await this.findOne(scenarioId); // 404 guard
return this.scenarioCredRepo.find({
where: { scenarioId },
relations: ["credential"],
order: { alias: "ASC" },
});
}
async addScenarioCredential(
scenarioId: number,
dto: AddScenarioCredentialDto,
): Promise<ScenarioCredentialEntity> {
await this.findOne(scenarioId); // 404 guard
const credential = await this.credentialRepo.findOneBy({
id: dto.credentialId,
});
if (!credential)
throw new NotFoundException(`Credential ${dto.credentialId} not found`);
const existing = await this.scenarioCredRepo.findOneBy({
scenarioId,
alias: dto.alias,
});
if (existing)
throw new ConflictException(
`Alias "${dto.alias}" already used in this scenario`,
);
const sc = this.scenarioCredRepo.create({
scenarioId,
credentialId: dto.credentialId,
alias: dto.alias,
});
return this.scenarioCredRepo.save(sc);
}
async removeScenarioCredential(
scenarioId: number,
scCredId: number,
): Promise<void> {
await this.findOne(scenarioId); // 404 guard
const sc = await this.scenarioCredRepo.findOneBy({
id: scCredId,
scenarioId,
});
if (!sc)
throw new NotFoundException(
`Scenario credential ${scCredId} not found in scenario ${scenarioId}`,
);
await this.scenarioCredRepo.delete(scCredId);
}
/**
* Builds a map of alias → parsed credential data for use in code execution.
* Returns null values for credentials with no data.
*/
async buildCredentialMap(scenarioId: number): Promise<Record<string, unknown>> {
const scs = await this.findScenarioCredentials(scenarioId);
const map: Record<string, unknown> = {};
for (const sc of scs) {
let parsed: unknown = null;
if (sc.credential.data) {
try {
parsed = JSON.parse(sc.credential.data);
} catch {
parsed = sc.credential.data;
}
}
map[sc.alias] = parsed;
}
return map;
}
// ── Runs ──────────────────────────────────────────────────────────────────
async findRuns(
scenarioId: number,
query: RunsQueryDto,