feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
private readonly logger = new Logger(UploadService.name);
|
||||
|
||||
constructor(
|
||||
private kbService: KnowledgeBaseService,
|
||||
private groupService: KnowledgeGroupService,
|
||||
) {}
|
||||
|
||||
async processUploadedFile(file: Express.Multer.File) {
|
||||
// Add more business logic here. Example:
|
||||
// - Save file info to database
|
||||
// - Call other services to process file (Tika text extraction, ES indexing etc.)
|
||||
// - Validate file format or analyze content
|
||||
|
||||
// Currently only return basic file info
|
||||
return {
|
||||
filename: file.filename,
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype,
|
||||
path: file.path, // After Multer saves file, full path is in file.path
|
||||
};
|
||||
}
|
||||
|
||||
async importLocalFolder(
|
||||
sourcePath: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
config: any,
|
||||
) {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new BadRequestException(`Directory not found: ${sourcePath}`);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new BadRequestException(`Path is not a directory: ${sourcePath}`);
|
||||
}
|
||||
|
||||
// Determine root group for hierarchy or single group
|
||||
let rootGroupId: string | null = null;
|
||||
if (config.groupIds && config.groupIds.length > 0) {
|
||||
rootGroupId = config.groupIds[0];
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Starting local folder import: ${sourcePath} for user ${userId}, tenant ${tenantId}`,
|
||||
);
|
||||
|
||||
// Trigger scanning and processing asynchronously to not block the request
|
||||
this.executeLocalImport(
|
||||
sourcePath,
|
||||
userId,
|
||||
tenantId,
|
||||
config,
|
||||
rootGroupId,
|
||||
).catch((err) => {
|
||||
this.logger.error(`Local folder import failed for ${sourcePath}`, err);
|
||||
});
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'PROCESSING',
|
||||
};
|
||||
}
|
||||
|
||||
private async executeLocalImport(
|
||||
sourcePath: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
config: any,
|
||||
rootGroupId: string | null,
|
||||
) {
|
||||
const files = this.scanDir(sourcePath);
|
||||
this.logger.log(`Found ${files.length} files in ${sourcePath}`);
|
||||
|
||||
const dirToGroupId = new Map<string, string>();
|
||||
if (rootGroupId) {
|
||||
dirToGroupId.set('.', rootGroupId);
|
||||
} else {
|
||||
// Create a root group based on folder name if none provided
|
||||
const rootName = path.basename(sourcePath);
|
||||
const rootGroup = await this.groupService.create(userId, tenantId, {
|
||||
name: rootName,
|
||||
description: `Imported from local path: ${sourcePath}`,
|
||||
});
|
||||
rootGroupId = rootGroup.id;
|
||||
dirToGroupId.set('.', rootGroupId);
|
||||
}
|
||||
|
||||
const uploadBaseDir = process.env.UPLOAD_FILE_PATH || './uploads';
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const relativeDir = path.relative(sourcePath, path.dirname(filePath));
|
||||
const normalizedDir = relativeDir || '.';
|
||||
|
||||
let targetGroupId = rootGroupId;
|
||||
if (config.useHierarchy) {
|
||||
targetGroupId = await this.ensureHierarchy(
|
||||
userId,
|
||||
tenantId,
|
||||
normalizedDir,
|
||||
dirToGroupId,
|
||||
rootGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const storedFilename = `local-${uniqueSuffix}-${filename}`;
|
||||
|
||||
// Ensure tenant directory exists
|
||||
const tenantDir = path.join(uploadBaseDir, tenantId);
|
||||
if (!fs.existsSync(tenantDir)) {
|
||||
fs.mkdirSync(tenantDir, { recursive: true });
|
||||
}
|
||||
|
||||
const targetPath = path.join(tenantDir, storedFilename);
|
||||
fs.copyFileSync(filePath, targetPath);
|
||||
|
||||
const stats = fs.statSync(targetPath);
|
||||
const fileInfo = {
|
||||
filename: storedFilename,
|
||||
originalname: filename,
|
||||
path: targetPath,
|
||||
size: stats.size,
|
||||
mimetype: this.getMimeType(filename),
|
||||
};
|
||||
|
||||
await this.kbService.createAndIndex(fileInfo, userId, tenantId, {
|
||||
...config,
|
||||
groupIds: [targetGroupId],
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to process local file: ${filePath}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Local folder import completed: ${sourcePath}`);
|
||||
}
|
||||
|
||||
private async ensureHierarchy(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
relativeDir: string,
|
||||
dirToGroupId: Map<string, string>,
|
||||
rootGroupId: string,
|
||||
): Promise<string> {
|
||||
if (dirToGroupId.has(relativeDir)) {
|
||||
return dirToGroupId.get(relativeDir)!;
|
||||
}
|
||||
|
||||
const segments = relativeDir.split(path.sep);
|
||||
let currentPath = '';
|
||||
let parentId = rootGroupId;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!segment || segment === '.') continue;
|
||||
currentPath = currentPath ? path.join(currentPath, segment) : segment;
|
||||
|
||||
if (dirToGroupId.has(currentPath)) {
|
||||
parentId = dirToGroupId.get(currentPath)!;
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = await this.groupService.findOrCreate(
|
||||
userId,
|
||||
tenantId,
|
||||
segment,
|
||||
parentId,
|
||||
`Sub-folder from local import: ${currentPath}`,
|
||||
);
|
||||
dirToGroupId.set(currentPath, group.id);
|
||||
parentId = group.id;
|
||||
}
|
||||
|
||||
return parentId;
|
||||
}
|
||||
|
||||
private scanDir(directory: string): string[] {
|
||||
let results: string[] = [];
|
||||
if (!fs.existsSync(directory)) return results;
|
||||
|
||||
const items = fs.readdirSync(directory);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(directory, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
results = results.concat(this.scanDir(fullPath));
|
||||
} else {
|
||||
// Only include supported document and code extensions
|
||||
const ext = path.extname(item).toLowerCase().slice(1);
|
||||
if (
|
||||
[
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'rtf',
|
||||
'csv',
|
||||
'txt',
|
||||
'md',
|
||||
'html',
|
||||
'json',
|
||||
'xml',
|
||||
'js',
|
||||
'ts',
|
||||
'py',
|
||||
'java',
|
||||
'sql',
|
||||
].includes(ext)
|
||||
) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.md': 'text/markdown',
|
||||
'.txt': 'text/plain',
|
||||
'.json': 'application/json',
|
||||
'.html': 'text/html',
|
||||
'.csv': 'text/csv',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user