import { BadRequestException, ConflictException, Injectable, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import * as fs from "fs/promises"; import * as path from "path"; import { Like, Repository } from "typeorm"; import { PaginatedResult, PaginationQueryDto, } from "../common/dto/pagination.dto"; import { CredentialEntity } from "../credential/credential.entity"; import { EnvironmentEntity } from "../environment/environment.entity"; import { FileStorageService } from "../file/file-storage.service"; import { ScenarioFileEntity } from "../file/scenario-file.entity"; import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity"; import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto"; import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto"; import { CreateScenarioDto } from "./dto/create-scenario.dto"; import { RunsQueryDto } from "./dto/runs-query.dto"; import { CredentialExportDto, EnvironmentExportInlineDto, ExportEntity, ScenarioExportDto, } from "./dto/scenario-export.dto"; import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto"; import { UpdateScenarioDto } from "./dto/update-scenario.dto"; import { ScenarioCredentialEntity } from "./scenario-credential.entity"; import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; import { ScenarioRunEntity } from "./scenario-run.entity"; import { ScenarioStepEntity } from "./scenario-step.entity"; import { ScenarioEntity } from "./scenario.entity"; import { Response } from "express"; import type { File as MulterFile } from "multer"; 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, @InjectRepository(ScenarioFileEntity) private readonly scenarioFileRepo: Repository, @InjectRepository(ScenarioRunFileEntity) private readonly scenarioRunFileRepo: Repository, private readonly fileStorageService: FileStorageService, ) {} // ── 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, }); let lastRunByScenario = new Map(); if (data.length > 0) { const ids = data.map((s) => s.id); const latestRuns = await this.runRepo .createQueryBuilder("r") .select(["r.scenarioId", "r.status", "r.createdAt"]) .where("r.scenarioId IN (:...ids)", { ids }) .andWhere((qb) => { const sub = qb .subQuery() .select("MAX(r2.createdAt)") .from(ScenarioRunEntity, "r2") .where("r2.scenarioId = r.scenarioId") .getQuery(); return `r.createdAt = (${sub})`; }) .getMany(); lastRunByScenario = new Map( latestRuns.map((r) => [r.scenarioId, { status: r.status, createdAt: r.createdAt }]), ); } return { data: data.map((s) => ({ ...s, lastRunStatus: lastRunByScenario.get(s.id)?.status ?? null, lastRunAt: lastRunByScenario.get(s.id)?.createdAt?.toISOString() ?? null, })), total, page, limit, }; } async findOne(id: string): Promise { const scenario = await this.scenarioRepo.findOne({ where: { id }, relations: [ "steps", "scenarioCredentials", "scenarioCredentials.credential", "environment", ], 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; } if (scs.length > 0) { const ids = scs.map((sc) => sc.credentialId); await this.credentialRepo .createQueryBuilder() .update() .set({ lastUsedAt: () => "CURRENT_TIMESTAMP" }) .whereInIds(ids) .execute(); } 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 orderBy = query.orderBy ?? "createdAt"; const orderDir = query.orderDir ?? "DESC"; const where: Record = { scenarioId }; if (query.status) where["status"] = query.status; const [data, total] = await this.runRepo.findAndCount({ where, relations: ["stepRuns"], order: { [orderBy]: orderDir }, 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 orderBy = query.orderBy ?? "createdAt"; const orderDir = query.orderDir ?? "DESC"; const qb = this.runRepo .createQueryBuilder("run") .leftJoinAndSelect("run.scenario", "scenario"); if (query.status) qb.where("run.status = :status", { status: query.status }); if (orderBy === "scenario.name") { qb.orderBy("scenario.name", orderDir); } else { qb.orderBy(`run.${orderBy}`, orderDir); } qb.skip((page - 1) * limit).take(limit); const [data, total] = await qb.getManyAndCount(); 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", "environment", "session", ], 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: { seq: "ASC", createdAt: "ASC" }, }); return Object.assign(run, { logs }); } async exportRunLogs( scenarioId: string, runId: string, format: "csv" | "md", ): Promise { await this.findOne(scenarioId); // 404 guard const run = await this.runRepo.findOne({ where: { id: runId, scenarioId }, }); if (!run) throw new NotFoundException( `Run ${runId} not found in scenario ${scenarioId}`, ); const logs = await this.runLogRepo.find({ where: { runId }, order: { seq: "ASC", createdAt: "ASC" }, }); if (format === "csv") { const escapeCsv = (value: string): string => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; const rows = [ ["Timestamp", "Level", "Message"].map(escapeCsv).join(","), ...logs.map((l) => [l.createdAt.toISOString(), l.level, l.message] .map((v) => escapeCsv(String(v))) .join(","), ), ]; return rows.join("\n"); } const sections = logs.map( (l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`, ); return [`# Run ${runId} logs`, ...sections].join("\n\n"); } 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, saveSession = false, ): 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", saveSession, }), ); 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", "environment", "session"], order: { stepRuns: { order: "ASC" } }, }) as Promise; } // ── Export / Import ─────────────────────────────────────────────────────── async exportScenario( id: string, options: { includeEnvironment: boolean; credentialIds: string[] }, ): Promise { const scenario = await this.findOne(id); const entities: ExportEntity[] = []; // Include environment entity if requested and linked if (options.includeEnvironment && scenario.environment) { const envDto: EnvironmentExportInlineDto = { kind: "environment", id: scenario.environment.id, name: scenario.environment.name, description: scenario.environment.description ?? undefined, data: scenario.environment.data, }; entities.push(envDto); } // Include selected credentials if (options.credentialIds.length > 0) { const creds = scenario.scenarioCredentials ?? []; for (const sc of creds) { if ( sc.credential && options.credentialIds.includes(sc.credential.id) ) { const credDto: CredentialExportDto = { kind: "credential", id: sc.credential.id, name: sc.credential.name, data: sc.credential.data, }; entities.push(credDto); } } } // Scenario is always last const allCreds = scenario.scenarioCredentials ?? []; const credentialMappings = allCreds .filter( (sc) => options.credentialIds.length === 0 || options.credentialIds.includes(sc.credentialId), ) .map((sc) => ({ credentialId: sc.credentialId, alias: sc.alias })); const scenarioDto: ScenarioExportDto = { kind: "scenario", id: scenario.id, name: scenario.name, description: scenario.description ?? undefined, environmentId: scenario.environmentId ?? undefined, credentials: credentialMappings.length > 0 ? credentialMappings : undefined, steps: scenario.steps.map((s) => ({ title: s.title, execCode: s.execCode, })), }; entities.push(scenarioDto); return entities; } async importScenario( payload: ScenarioExportDto | ExportEntity[], ): Promise { // Accept plain {name, steps} objects (no kind) as well as the full export format const raw = payload as unknown as Record; if (!Array.isArray(payload) && !raw["kind"]) { if (typeof raw["name"] !== "string" || !raw["name"]) { throw new BadRequestException("name is required"); } if (!Array.isArray(raw["steps"])) { throw new BadRequestException("steps must be an array"); } (payload as unknown as Record)["kind"] = "scenario"; } const items: ExportEntity[] = Array.isArray(payload) ? payload : [payload]; let scenarioDto: ScenarioExportDto | undefined; for (const item of items) { if (item.kind === "environment") { await this.environmentRepo .findOneBy({ id: item.id }) .then(async (existing) => { if (existing) { Object.assign(existing, { name: item.name, description: (item as EnvironmentExportInlineDto).description ?? null, data: (item as EnvironmentExportInlineDto).data, }); return this.environmentRepo.save(existing); } return this.environmentRepo.save( this.environmentRepo.create({ id: item.id, name: item.name, description: (item as EnvironmentExportInlineDto).description ?? null, data: (item as EnvironmentExportInlineDto).data, }), ); }); } else if (item.kind === "credential") { const credItem = item as CredentialExportDto; const existingCred = credItem.id ? await this.credentialRepo.findOneBy({ id: credItem.id }) : null; if (existingCred) { existingCred.name = credItem.name; existingCred.data = credItem.data ?? null; await this.credentialRepo.save(existingCred); } else { await this.credentialRepo.save( this.credentialRepo.create({ ...(credItem.id ? { id: credItem.id } : {}), name: credItem.name, data: credItem.data ?? null, }), ); } } else if (!item.kind || item.kind === "scenario") { scenarioDto = item as ScenarioExportDto; } } if (!scenarioDto) { throw new BadRequestException("No scenario entity found in import payload"); } if (typeof scenarioDto.name !== "string" || !scenarioDto.name) { throw new BadRequestException("name is required"); } if (!Array.isArray(scenarioDto.steps)) { throw new BadRequestException("steps must be an array"); } const dto = scenarioDto; let scenario: ScenarioEntity; if (dto.id) { const existing = await this.scenarioRepo.findOneBy({ id: dto.id }); if (existing) { existing.name = dto.name; existing.description = dto.description ?? null; existing.environmentId = dto.environmentId ?? existing.environmentId; scenario = await this.scenarioRepo.save(existing); await this.stepRepo.delete({ scenarioId: scenario.id }); } else { scenario = await this.scenarioRepo.save( this.scenarioRepo.create({ id: dto.id, name: dto.name, description: dto.description ?? null, environmentId: dto.environmentId ?? null, }), ); } } else { scenario = await this.scenarioRepo.save( this.scenarioRepo.create({ name: dto.name, description: dto.description ?? null, environmentId: dto.environmentId ?? null, }), ); } 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); } // Restore credential alias mappings if (dto.credentials && dto.credentials.length > 0) { await this.scenarioCredRepo.delete({ scenarioId: scenario.id }); const mappings = dto.credentials.map((c) => this.scenarioCredRepo.create({ scenarioId: scenario.id, credentialId: c.credentialId, alias: c.alias, }), ); await this.scenarioCredRepo.save(mappings); } return this.findOne(scenario.id); } // ── Files ───────────────────────────────────────────────────────────────── async uploadFile( scenarioId: string, file: MulterFile, expiresAtStr?: string, ): Promise { // Verify scenario exists await this.findOne(scenarioId); if (!file) { throw new BadRequestException("No file provided"); } // Parse optional expiresAt let expiresAt: Date | undefined = undefined; if (expiresAtStr) { expiresAt = new Date(expiresAtStr); if (isNaN(expiresAt.getTime())) { throw new BadRequestException("Invalid expiresAt date"); } } // Save file to disk and database const savedFile = await this.fileStorageService.saveFile( file.buffer, file.originalname, file.mimetype, expiresAt, ); // Create scenario file mapping const scenarioFile = this.scenarioFileRepo.create({ scenarioId, fileId: savedFile.id, }); await this.scenarioFileRepo.save(scenarioFile); // Return file metadata return { id: savedFile.id, name: savedFile.originalName, mimeType: savedFile.mimeType, size: savedFile.size, expiresAt: savedFile.expiresAt, createdAt: savedFile.createdAt, }; } async listScenarioFiles( scenarioId: string, limit?: number, offset?: number, ): Promise { // Verify scenario exists await this.findOne(scenarioId); const result = await this.fileStorageService.listScenarioFiles( scenarioId, limit, offset, ); return { items: result.items.map((item) => ({ id: item.id, name: item.name, mimeType: item.mimeType, size: item.size, sha256: item.sha256, expiresAt: item.expiresAt, createdAt: item.createdAt, })), total: result.total, }; } async getScenarioFileContent( scenarioId: string, fileId: string, res: Response, ): Promise { // Verify scenario exists await this.findOne(scenarioId); // Check if file is linked to this scenario const link = await this.scenarioFileRepo.findOne({ where: { scenarioId, fileId }, relations: ["file"], }); if (!link) { throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`); } const file = link.file; const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); res.setHeader("Content-Type", file.mimeType); res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`); res.setHeader("Content-Length", file.size); res.sendFile(fullPath); } /** * Get scenario file content as a buffer (for MCP use). * Returns metadata plus raw bytes without streaming to Response. */ async getScenarioFileContentAsBuffer( scenarioId: string, fileId: string, ): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> { // Verify scenario exists await this.findOne(scenarioId); // Check if file is linked to this scenario const link = await this.scenarioFileRepo.findOne({ where: { scenarioId, fileId }, relations: ["file"], }); if (!link) { throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`); } const file = link.file; const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); // Read file content as buffer const contentBuffer = await fs.readFile(fullPath); return { file: { id: file.id, name: file.originalName, mimeType: file.mimeType, size: file.size, sha256: file.sha256, expiresAt: file.expiresAt, createdAt: file.createdAt, }, contentBuffer, }; } async listRunFiles( scenarioId: string, runId: string, limit?: number, offset?: number, ): Promise { // Verify scenario and run exist await this.findOne(scenarioId); const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); if (!run) { throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`); } const result = await this.fileStorageService.listRunFiles( runId, limit, offset, ); return { items: result.items.map((item) => ({ id: item.id, name: item.name, mimeType: item.mimeType, size: item.size, sha256: item.sha256, expiresAt: item.expiresAt, createdAt: item.createdAt, })), total: result.total, }; } async getRunFileContent( scenarioId: string, runId: string, fileId: string, res: Response, ): Promise { // Verify scenario and run exist await this.findOne(scenarioId); const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); if (!run) { throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`); } // Check if file is linked to this run const link = await this.scenarioRunFileRepo.findOne({ where: { runId, fileId }, relations: ["file"], }); if (!link) { throw new NotFoundException(`File ${fileId} not found in run ${runId}`); } const file = link.file; const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); res.setHeader("Content-Type", file.mimeType); res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`); res.setHeader("Content-Length", file.size); res.sendFile(fullPath); } /** * Get run artifact file content as a buffer (for MCP use). * Returns metadata plus raw bytes without streaming to Response. */ async getRunFileContentAsBuffer( scenarioId: string, runId: string, fileId: string, ): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> { // Verify scenario and run exist await this.findOne(scenarioId); const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); if (!run) { throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`); } // Check if file is linked to this run const link = await this.scenarioRunFileRepo.findOne({ where: { runId, fileId }, relations: ["file"], }); if (!link) { throw new NotFoundException(`File ${fileId} not found in run ${runId}`); } const file = link.file; const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); // Read file content as buffer const contentBuffer = await fs.readFile(fullPath); return { file: { id: file.id, name: file.originalName, mimeType: file.mimeType, size: file.size, sha256: file.sha256, expiresAt: file.expiresAt, createdAt: file.createdAt, }, contentBuffer, }; } }