feat(scenarios): overhaul export/import with multi-entity YAML format

- export now accepts includeEnvironment and credentialIds query params
- output is a YAML stream with comment-delimited environment, credential, and scenario sections
- scenario entity includes credentials array with alias mappings
- import accepts both old single-object and new array format, upserts envs/creds before linking
- new ExportScenarioModal with env and per-credential checkboxes replaces direct download
This commit is contained in:
2026-04-15 00:35:14 +03:00
parent a779ab4667
commit f268d0e3eb
10 changed files with 489 additions and 29 deletions
+130 -6
View File
@@ -15,7 +15,12 @@ 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 { ScenarioExportDto } from "./dto/scenario-export.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";
@@ -405,30 +410,135 @@ export class ScenarioService {
// ── Export / Import ───────────────────────────────────────────────────────
async exportScenario(id: string): Promise<ScenarioExportDto> {
async exportScenario(
id: string,
options: { includeEnvironment: boolean; credentialIds: string[] },
): Promise<ExportEntity[]> {
const scenario = await this.findOne(id);
return {
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(dto: ScenarioExportDto): Promise<ScenarioEntity> {
// Upsert: if id provided and entity exists, replace steps; else create new
async importScenario(
payload: ScenarioExportDto | ExportEntity[],
): Promise<ScenarioEntity> {
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 === "scenario") {
scenarioDto = item as ScenarioExportDto;
}
}
if (!scenarioDto) {
throw new Error("No scenario entity found in import payload");
}
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);
// Delete old steps and recreate
await this.stepRepo.delete({ scenarioId: scenario.id });
} else {
scenario = await this.scenarioRepo.save(
@@ -436,6 +546,7 @@ export class ScenarioService {
id: dto.id,
name: dto.name,
description: dto.description ?? null,
environmentId: dto.environmentId ?? null,
}),
);
}
@@ -444,6 +555,7 @@ export class ScenarioService {
this.scenarioRepo.create({
name: dto.name,
description: dto.description ?? null,
environmentId: dto.environmentId ?? null,
}),
);
}
@@ -459,6 +571,18 @@ export class ScenarioService {
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);
}
}