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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user