Files
liqa/server/src/scenario/scenario.service.ts
T
ars9 259dac806e refactor(auth): remove keys auth module and align tests
- remove server auth module and client keys page/routes/api to simplify flow

- update mcp and scenario tests to match uuid routes and step schema
2026-04-10 16:42:43 +03:00

379 lines
12 KiB
TypeScript

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 { 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<ScenarioEntity>,
@InjectRepository(ScenarioStepEntity)
private readonly stepRepo: Repository<ScenarioStepEntity>,
@InjectRepository(ScenarioRunEntity)
private readonly runRepo: Repository<ScenarioRunEntity>,
@InjectRepository(ScenarioRunStepEntity)
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 ─────────────────────────────────────────────────────────────
create(dto: CreateScenarioDto): Promise<ScenarioEntity> {
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
}
async findAll(
query: PaginationQueryDto<ScenarioOrderBy>,
): Promise<PaginatedResult<ScenarioEntity>> {
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<ScenarioEntity> {
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<ScenarioEntity> {
const scenario = await this.findOne(id);
Object.assign(scenario, dto);
return this.scenarioRepo.save(scenario);
}
async remove(id: string): Promise<void> {
await this.findOne(id);
await this.scenarioRepo.delete(id);
}
// ── Steps ─────────────────────────────────────────────────────────────────
async createStep(
scenarioId: string,
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: string,
stepId: string,
): 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: string,
stepId: string,
dto: UpdateScenarioStepDto,
): Promise<ScenarioStepEntity> {
const step = await this.findStep(scenarioId, stepId);
Object.assign(step, dto);
return this.stepRepo.save(step);
}
async removeStep(scenarioId: string, stepId: string): Promise<void> {
await this.findStep(scenarioId, stepId);
await this.stepRepo.delete(stepId);
}
// ── Scenario Credentials ──────────────────────────────────────────────────
async findScenarioCredentials(
scenarioId: string,
): Promise<ScenarioCredentialEntity[]> {
await this.findOne(scenarioId); // 404 guard
return this.scenarioCredRepo.find({
where: { scenarioId },
relations: ["credential"],
order: { alias: "ASC" },
});
}
async addScenarioCredential(
scenarioId: string,
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: string,
scCredId: string,
): 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: string,
): 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: string,
query: RunsQueryDto,
): Promise<PaginatedResult<ScenarioRunEntity>> {
await this.findOne(scenarioId); // 404 guard
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Record<string, unknown> = { 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<ScenarioRunEntity & { scenario: ScenarioEntity }>
> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Record<string, unknown> = {};
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<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
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<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
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<void>((resolve) => setTimeout(resolve, 500));
}
return this.findRun(scenarioId, runId);
}
async createRun(scenarioId: string): Promise<ScenarioRunEntity> {
const scenario = await this.findOne(scenarioId);
const run = await this.runRepo.save(
this.runRepo.create({ scenarioId, 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<ScenarioRunEntity>;
}
// ── Export / Import ───────────────────────────────────────────────────────
async exportScenario(id: string): Promise<ScenarioExportDto> {
const scenario = await this.findOne(id);
return {
kind: "scenario",
id: scenario.id,
name: scenario.name,
steps: scenario.steps.map((s) => ({
order: s.order,
title: s.title,
execCode: s.execCode,
validateCode: s.validateCode,
})),
};
}
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
// 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) =>
this.stepRepo.create({
scenarioId: scenario.id,
order: s.order,
title: s.title ?? null,
execCode: s.execCode ?? null,
validateCode: s.validateCode ?? null,
}),
);
await this.stepRepo.save(steps);
}
return this.findOne(scenario.id);
}
}