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,362 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
Res,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import * as path from 'path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { KnowledgeBaseService } from './knowledge-base.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { UserRole } from '../user/user-role.enum';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { KnowledgeBase } from './knowledge-base.entity';
|
||||
import { ChunkConfigService } from './chunk-config.service';
|
||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Controller('knowledge-bases')
|
||||
@UseGuards(CombinedAuthGuard, RolesGuard)
|
||||
export class KnowledgeBaseController {
|
||||
private readonly logger = new Logger(KnowledgeBaseController.name);
|
||||
|
||||
constructor(
|
||||
private readonly knowledgeBaseService: KnowledgeBaseService,
|
||||
private readonly chunkConfigService: ChunkConfigService,
|
||||
private readonly knowledgeGroupService: KnowledgeGroupService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async findAll(@Request() req): Promise<KnowledgeBase[]> {
|
||||
return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async getStats(
|
||||
@Request() req,
|
||||
): Promise<{ total: number; uncategorized: number }> {
|
||||
return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Delete('clear')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async clearAll(@Request() req): Promise<{ message: string }> {
|
||||
await this.knowledgeBaseService.clearAll(req.user.id, req.user.tenantId);
|
||||
return { message: this.i18nService.getMessage('kbCleared') };
|
||||
}
|
||||
|
||||
@Post('search')
|
||||
async search(@Request() req, @Body() body: { query: string; topK?: number }) {
|
||||
return this.knowledgeBaseService.searchKnowledge(
|
||||
req.user.id,
|
||||
req.user.tenantId, // New
|
||||
body.query,
|
||||
body.topK || 5,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rag-search')
|
||||
async ragSearch(
|
||||
@Request() req,
|
||||
@Body() body: { query: string; settings: any },
|
||||
) {
|
||||
return this.knowledgeBaseService.ragSearch(
|
||||
req.user.id,
|
||||
req.user.tenantId, // New
|
||||
body.query,
|
||||
body.settings,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async deleteFile(
|
||||
@Request() req,
|
||||
@Param('id') fileId: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.knowledgeBaseService.deleteFile(
|
||||
fileId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
return { message: this.i18nService.getMessage('fileDeleted') };
|
||||
}
|
||||
|
||||
@Post(':id/retry')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async retryFile(
|
||||
@Request() req,
|
||||
@Param('id') fileId: string,
|
||||
): Promise<KnowledgeBase> {
|
||||
return this.knowledgeBaseService.retryFailedFile(
|
||||
fileId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/chunks')
|
||||
async getFileChunks(@Request() req, @Param('id') fileId: string) {
|
||||
return this.knowledgeBaseService.getFileChunks(
|
||||
fileId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk config limits (for frontend slider settings)
|
||||
* Query parameter: embeddingModelId - embedding model ID
|
||||
*/
|
||||
@Get('chunk-config/limits')
|
||||
async getChunkConfigLimits(
|
||||
@Request() req,
|
||||
@Query('embeddingModelId') embeddingModelId: string,
|
||||
) {
|
||||
if (!embeddingModelId) {
|
||||
return {
|
||||
maxChunkSize: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
|
||||
maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '2000'),
|
||||
minOverlapSize: 25,
|
||||
defaultChunkSize: 200,
|
||||
defaultOverlapSize: 40,
|
||||
modelInfo: {
|
||||
name: this.i18nService.getMessage('modelNotConfigured'),
|
||||
maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
|
||||
maxBatchSize: 2048,
|
||||
expectedDimensions: parseInt(
|
||||
process.env.DEFAULT_VECTOR_DIMENSIONS || '2560',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await this.chunkConfigService.getFrontendLimits(
|
||||
embeddingModelId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
// File group management - requires admin permission
|
||||
@Post(':id/groups')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async addFileToGroups(
|
||||
@Param('id') fileId: string,
|
||||
@Body() body: { groupIds: string[] },
|
||||
@Request() req,
|
||||
) {
|
||||
await this.knowledgeGroupService.addFilesToGroup(
|
||||
fileId,
|
||||
body.groupIds,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
return { message: this.i18nService.getMessage('groupSyncSuccess') };
|
||||
}
|
||||
|
||||
@Delete(':id/groups/:groupId')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async removeFileFromGroup(
|
||||
@Param('id') fileId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@Request() req,
|
||||
) {
|
||||
await this.knowledgeGroupService.removeFileFromGroup(
|
||||
fileId,
|
||||
groupId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
|
||||
}
|
||||
|
||||
// PDF preview - public access
|
||||
@Public()
|
||||
@Get(':id/pdf')
|
||||
async getPDFPreview(
|
||||
@Param('id') fileId: string,
|
||||
@Query('token') token: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
if (!token) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('accessDeniedNoToken'),
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new InternalServerErrorException(
|
||||
this.i18nService.getMessage('jwtSecretRequired'),
|
||||
);
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, secret) as any;
|
||||
} catch {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('invalidToken'),
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('invalidToken'),
|
||||
);
|
||||
}
|
||||
|
||||
const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
|
||||
fileId,
|
||||
decoded.userId,
|
||||
decoded.tenantId, // New
|
||||
);
|
||||
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
|
||||
if (!fs.existsSync(pdfPath)) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('pdfFileNotFound'),
|
||||
);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(pdfPath);
|
||||
const fileName = path.basename(pdfPath);
|
||||
|
||||
if (stat.size === 0) {
|
||||
this.logger.warn(`PDF file is empty: ${pdfPath}`);
|
||||
try {
|
||||
fs.unlinkSync(pdfPath); // Delete empty file
|
||||
} catch (e) {}
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('pdfFileEmpty'),
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
|
||||
const stream = fs.createReadStream(pdfPath);
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`PDF preview error: ${error.message}`);
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('pdfConversionFailed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get PDF preview URL
|
||||
@Get(':id/pdf-url')
|
||||
async getPDFUrl(
|
||||
@Param('id') fileId: string,
|
||||
@Query('force') force: string,
|
||||
@Request() req,
|
||||
) {
|
||||
try {
|
||||
// Trigger PDF conversion
|
||||
await this.knowledgeBaseService.ensurePDFExists(
|
||||
fileId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
force === 'true',
|
||||
);
|
||||
|
||||
// Generate temporary access token
|
||||
const jwt = await import('jsonwebtoken');
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new InternalServerErrorException(
|
||||
this.i18nService.getMessage('jwtSecretRequired'),
|
||||
);
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
fileId,
|
||||
userId: req.user.id,
|
||||
tenantId: req.user.tenantId,
|
||||
type: 'pdf-access',
|
||||
},
|
||||
secret,
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
|
||||
return {
|
||||
url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.message.includes('LibreOffice')) {
|
||||
throw new InternalServerErrorException(
|
||||
this.i18nService.formatMessage('pdfServiceUnavailable', {
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/pdf-status')
|
||||
async getPDFStatus(@Param('id') fileId: string, @Request() req) {
|
||||
return await this.knowledgeBaseService.getPDFStatus(
|
||||
fileId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
// Get specific page of PDF as image
|
||||
@Get(':id/page/:index')
|
||||
async getPageImage(
|
||||
@Param('id') fileId: string,
|
||||
@Param('index') index: number,
|
||||
@Request() req,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const imagePath = await this.knowledgeBaseService.getPageAsImage(
|
||||
fileId,
|
||||
Number(index),
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
|
||||
const fs = await import('fs');
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('pageImageNotFound'),
|
||||
);
|
||||
}
|
||||
|
||||
res.sendFile(path.resolve(imagePath));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get PDF page image: ${error.message}`);
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('pdfPageImageFailed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user