feat(files): add file storage subsystem with scenario and run artifact support

- add FileEntity, ScenarioFileEntity, ScenarioRunFileEntity with UUID-sharded disk tree
- FileStorageService: write/sha256/expiry, paginated listing, dynamic cleanup cron via SchedulerRegistry
- expose getScenarioFiles() and downloadFile() on ScriptContext for use in exec code
- wire scenarioId, runId, fileService into ExecContextBuilder and scenario scheduler
- add file upload and listing endpoints to ScenarioController
- add FILES_DIR, FILE_RUN_EXPIRATION_DAYS, FILE_CLEANUP_CRON to AppConfig
This commit is contained in:
2026-04-21 13:01:09 +03:00
parent aa3bae9020
commit 6a91ce30e3
15 changed files with 864 additions and 0 deletions
+175
View File
@@ -12,6 +12,9 @@ import {
} 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";
@@ -30,6 +33,8 @@ 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";
@@ -53,6 +58,11 @@ export class ScenarioService {
private readonly credentialRepo: Repository<CredentialEntity>,
@InjectRepository(EnvironmentEntity)
private readonly environmentRepo: Repository<EnvironmentEntity>,
@InjectRepository(ScenarioFileEntity)
private readonly scenarioFileRepo: Repository<ScenarioFileEntity>,
@InjectRepository(ScenarioRunFileEntity)
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
private readonly fileStorageService: FileStorageService,
) {}
// ── Scenarios ─────────────────────────────────────────────────────────────
@@ -651,4 +661,169 @@ export class ScenarioService {
}
return this.findOne(scenario.id);
}
// ── Files ─────────────────────────────────────────────────────────────────
async uploadFile(
scenarioId: string,
file: MulterFile,
expiresAtStr?: string,
): Promise<any> {
// 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<any> {
// 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,
})),
total: result.total,
};
}
async getScenarioFileContent(
scenarioId: string,
fileId: string,
res: Response,
): Promise<void> {
// 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 = 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);
}
async listRunFiles(
scenarioId: string,
runId: string,
limit?: number,
offset?: number,
): Promise<any> {
// 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,
})),
total: result.total,
};
}
async getRunFileContent(
scenarioId: string,
runId: string,
fileId: string,
res: Response,
): Promise<void> {
// 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 = 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);
}
}