import { ConflictException, Injectable, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Like, Repository } from "typeorm"; import { ScenarioEntity } from "./scenario.entity"; 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 { EnvironmentEntity } from "../environment/environment.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, } from "../common/dto/pagination.dto"; import { RunsQueryDto } from "./dto/runs-query.dto"; import { ScenarioExportDto } from "./dto/scenario-export.dto"; export { PaginatedResult } from "../common/dto/pagination.dto"; export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt"; @Injectable() export class ScenarioService { constructor( @InjectRepository(ScenarioEntity) private readonly scenarioRepo: Repository, @InjectRepository(ScenarioStepEntity) private readonly stepRepo: Repository, @InjectRepository(ScenarioRunEntity) private readonly runRepo: Repository, @InjectRepository(ScenarioRunStepEntity) private readonly runStepRepo: Repository, @InjectRepository(ScenarioRunLogEntity) private readonly runLogRepo: Repository, @InjectRepository(ScenarioCredentialEntity) private readonly scenarioCredRepo: Repository, @InjectRepository(CredentialEntity) private readonly credentialRepo: Repository, @InjectRepository(EnvironmentEntity) private readonly environmentRepo: 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 orderBy = query.orderBy ?? "id"; const orderDir = query.orderDir ?? "ASC"; const [data, total] = await this.scenarioRepo.findAndCount({ order: { [orderBy]: orderDir }, skip: (page - 1) * limit, take: limit, }); return { data, total, page, limit }; } async findOne(id: string): Promise { const scenario = await this.scenarioRepo.findOne({ where: { id }, relations: [ "steps", "scenarioCredentials", "scenarioCredentials.credential", ], order: { steps: { order: "ASC" } }, }); if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); return scenario; } async update(id: string, dto: UpdateScenarioDto): Promise { const scenario = await this.findOne(id); Object.assign(scenario, dto); return this.scenarioRepo.save(scenario); } async remove(id: string): Promise { await this.findOne(id); await this.scenarioRepo.delete(id); } // ── Steps ───────────────────────────────────────────────────────────────── private async normalizeStepOrder(scenarioId: string): Promise { const ordered = await this.stepRepo.find({ where: { scenarioId }, order: { order: "ASC", createdAt: "ASC", id: "ASC" }, }); for (let i = 0; i < ordered.length; i += 1) { const step = ordered[i]; if (step.order !== i + 1) { step.order = i + 1; await this.stepRepo.save(step); } } } async createStep( scenarioId: string, dto: CreateScenarioStepDto, ): Promise { await this.findOne(scenarioId); const currentCount = await this.stepRepo.count({ where: { scenarioId } }); const created = await this.stepRepo.save( this.stepRepo.create({ ...dto, order: currentCount + 1, scenarioId, title: dto.title ?? null, execCode: dto.execCode ?? null, }), ); await this.normalizeStepOrder(scenarioId); return this.findStep(scenarioId, created.id); } async findStep( scenarioId: string, stepId: string, ): 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: string, stepId: string, dto: UpdateScenarioStepDto, ): Promise { const step = await this.findStep(scenarioId, stepId); const nextOrder = dto.order; Object.assign(step, { ...dto, order: step.order, title: dto.title ?? step.title, execCode: dto.execCode ?? step.execCode, }); await this.stepRepo.save(step); if (nextOrder !== undefined) { const ordered = await this.stepRepo.find({ where: { scenarioId }, order: { order: "ASC", createdAt: "ASC", id: "ASC" }, }); const currentIndex = ordered.findIndex((s) => s.id === stepId); if (currentIndex >= 0) { const boundedTarget = Math.max( 0, Math.min(nextOrder, ordered.length - 1), ); if (currentIndex !== boundedTarget) { const [moved] = ordered.splice(currentIndex, 1); ordered.splice(boundedTarget, 0, moved); } for (let i = 0; i < ordered.length; i += 1) { const item = ordered[i]; if (item.order !== i) { item.order = i; await this.stepRepo.save(item); } } } } else { await this.normalizeStepOrder(scenarioId); } return this.findStep(scenarioId, stepId); } async removeStep(scenarioId: string, stepId: string): Promise { await this.findStep(scenarioId, stepId); await this.stepRepo.delete(stepId); await this.normalizeStepOrder(scenarioId); } // ── Scenario Credentials ────────────────────────────────────────────────── async findScenarioCredentials( scenarioId: string, ): Promise { await this.findOne(scenarioId); // 404 guard return this.scenarioCredRepo.find({ where: { scenarioId }, relations: ["credential"], order: { alias: "ASC" }, }); } async addScenarioCredential( scenarioId: string, dto: AddScenarioCredentialDto, ): Promise { 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: string, scCredId: string, ): Promise { 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: string, ): Promise> { const scs = await this.findScenarioCredentials(scenarioId); const map: Record = {}; 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: string, query: RunsQueryDto, ): Promise> { await this.findOne(scenarioId); // 404 guard const page = query.page ?? 1; const limit = query.limit ?? 20; const where: Record = { scenarioId }; if (query.status) where["status"] = query.status; const [data, total] = await this.runRepo.findAndCount({ where, relations: ["stepRuns"], order: { createdAt: "DESC" }, skip: (page - 1) * limit, take: limit, }); return { data, total, page, limit }; } async findAllRuns( query: RunsQueryDto, ): Promise< PaginatedResult > { const page = query.page ?? 1; const limit = query.limit ?? 20; const where: Record = {}; if (query.status) where["status"] = query.status; const [data, total] = await this.runRepo.findAndCount({ where, relations: ["stepRuns", "scenario"], order: { createdAt: "DESC" }, skip: (page - 1) * limit, take: limit, }); return { data, total, page, limit } as PaginatedResult< ScenarioRunEntity & { scenario: ScenarioEntity } >; } async findRun( scenarioId: string, runId: string, q?: string, ): Promise { await this.findOne(scenarioId); // 404 guard const run = await this.runRepo.findOne({ where: { id: runId, scenarioId }, relations: ["stepRuns", "stepRuns.scenarioStep"], order: { stepRuns: { order: "ASC" } }, }); if (!run) throw new NotFoundException( `Run ${runId} not found in scenario ${scenarioId}`, ); const logs = await this.runLogRepo.find({ where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId }, order: { createdAt: "ASC" }, }); return Object.assign(run, { logs }); } async waitForRun( scenarioId: string, runId: string, timeoutMs = 300_000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); if (!run) throw new NotFoundException( `Run ${runId} not found in scenario ${scenarioId}`, ); if (run.status === "pass" || run.status === "fail") { return this.findRun(scenarioId, runId); } await new Promise((resolve) => setTimeout(resolve, 500)); } return this.findRun(scenarioId, runId); } async createRun( scenarioId: string, environmentId: string, ): Promise { const scenario = await this.findOne(scenarioId); const environment = await this.environmentRepo.findOneBy({ id: environmentId, }); if (!environment) { throw new NotFoundException(`Environment ${environmentId} not found`); } const run = await this.runRepo.save( this.runRepo.create({ scenarioId, environmentId, status: "pending" }), ); const stepRuns = scenario.steps.map((step, index) => this.runStepRepo.create({ runId: run.id, scenarioStepId: step.id, order: step.order, status: index === 0 ? "pending" : "waiting", description: null, }), ); await this.runStepRepo.save(stepRuns); return this.runRepo.findOne({ where: { id: run.id }, relations: ["stepRuns"], order: { stepRuns: { order: "ASC" } }, }) as Promise; } // ── Export / Import ─────────────────────────────────────────────────────── async exportScenario(id: string): Promise { const scenario = await this.findOne(id); return { kind: "scenario", id: scenario.id, name: scenario.name, steps: scenario.steps.map((s) => ({ title: s.title, execCode: s.execCode, })), }; } async importScenario(dto: ScenarioExportDto): Promise { // Upsert: if id provided and entity exists, replace steps; else create new let scenario: ScenarioEntity; if (dto.id) { const existing = await this.scenarioRepo.findOneBy({ id: dto.id }); if (existing) { existing.name = dto.name; scenario = await this.scenarioRepo.save(existing); // Delete old steps and recreate await this.stepRepo.delete({ scenarioId: scenario.id }); } else { scenario = await this.scenarioRepo.save( this.scenarioRepo.create({ id: dto.id, name: dto.name }), ); } } else { scenario = await this.scenarioRepo.save( this.scenarioRepo.create({ name: dto.name }), ); } if (dto.steps.length > 0) { const steps = dto.steps.map((s, index) => this.stepRepo.create({ scenarioId: scenario.id, order: index, title: s.title ?? null, execCode: s.execCode ?? null, }), ); await this.stepRepo.save(steps); await this.normalizeStepOrder(scenario.id); } return this.findOne(scenario.id); } }