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:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { SearchHistory } from './search-history.entity';
import { Tenant } from '../tenant/tenant.entity';
@Entity('chat_messages')
export class ChatMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'search_history_id' })
searchHistoryId: string;
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
tenantId: string;
@Column()
role: 'user' | 'assistant';
@Column('text')
content: string;
@Column({ nullable: true })
sources?: string; // JSON string
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => SearchHistory, (history) => history.messages, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'search_history_id' })
searchHistory: SearchHistory;
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
}
@@ -0,0 +1,69 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { SearchHistoryService } from './search-history.service';
import { I18nService } from '../i18n/i18n.service';
@Controller('search-history')
@UseGuards(CombinedAuthGuard)
export class SearchHistoryController {
constructor(
private readonly searchHistoryService: SearchHistoryService,
private readonly i18nService: I18nService,
) {}
@Get()
async findAll(
@Request() req,
@Query('page') page: string = '1',
@Query('limit') limit: string = '20',
) {
const pageNum = parseInt(page, 10) || 1;
const limitNum = parseInt(limit, 10) || 20;
return await this.searchHistoryService.findAll(
req.user.id,
req.user.tenantId,
pageNum,
limitNum,
);
}
@Get(':id')
async findOne(@Param('id') id: string, @Request() req) {
return await this.searchHistoryService.findOne(
id,
req.user.id,
req.user.tenantId,
);
}
@Post()
async create(
@Body() body: { title: string; selectedGroups?: string[] },
@Request() req,
) {
const history = await this.searchHistoryService.create(
req.user.id,
req.user.tenantId,
body.title,
body.selectedGroups,
);
return { id: history.id };
}
@Delete(':id')
async remove(@Param('id') id: string, @Request() req) {
await this.searchHistoryService.remove(id, req.user.id, req.user.tenantId);
return { message: this.i18nService.getMessage('searchHistoryDeleted') };
}
}
@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { ChatMessage } from './chat-message.entity';
@Entity('search_history')
export class SearchHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@Column()
title: string;
@Column({ name: 'selected_groups', nullable: true })
selectedGroups?: string; // JSON string
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToMany(() => ChatMessage, (message) => message.searchHistory)
messages: ChatMessage[];
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SearchHistory } from './search-history.entity';
import { ChatMessage } from './chat-message.entity';
import { SearchHistoryService } from './search-history.service';
import { SearchHistoryController } from './search-history.controller';
@Module({
imports: [TypeOrmModule.forFeature([SearchHistory, ChatMessage])],
controllers: [SearchHistoryController],
providers: [SearchHistoryService],
exports: [SearchHistoryService],
})
export class SearchHistoryModule {}
@@ -0,0 +1,197 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { I18nService } from '../i18n/i18n.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SearchHistory } from './search-history.entity';
import { ChatMessage } from './chat-message.entity';
export interface SearchHistoryItem {
id: string;
title: string;
selectedGroups: string[] | null;
messageCount: number;
lastMessageAt: string;
createdAt: string;
}
export interface SearchHistoryDetail {
id: string;
title: string;
selectedGroups: string[] | null;
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
sources?: any[];
createdAt: string;
}>;
}
export interface PaginatedSearchHistory {
histories: SearchHistoryItem[];
total: number;
page: number;
limit: number;
}
@Injectable()
export class SearchHistoryService {
constructor(
@InjectRepository(SearchHistory)
private searchHistoryRepository: Repository<SearchHistory>,
@InjectRepository(ChatMessage)
private chatMessageRepository: Repository<ChatMessage>,
private i18nService: I18nService,
) {}
async findAll(
userId: string,
tenantId: string,
page: number = 1,
limit: number = 20,
): Promise<PaginatedSearchHistory> {
const [histories, total] = await this.searchHistoryRepository.findAndCount({
where: { userId, tenantId },
order: { updatedAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
const items: SearchHistoryItem[] = await Promise.all(
histories.map(async (history) => {
const messageCount = await this.chatMessageRepository.count({
where: { searchHistoryId: history.id },
});
const lastMessage = await this.chatMessageRepository.findOne({
where: { searchHistoryId: history.id },
order: { createdAt: 'DESC' },
});
return {
id: history.id,
title: history.title,
selectedGroups: history.selectedGroups
? JSON.parse(history.selectedGroups)
: null,
messageCount,
lastMessageAt:
lastMessage?.createdAt.toISOString() ||
history.createdAt.toISOString(),
createdAt: history.createdAt.toISOString(),
};
}),
);
return {
histories: items,
total,
page,
limit,
};
}
async findOne(
id: string,
userId: string,
tenantId?: string,
): Promise<SearchHistoryDetail> {
const whereClause: any = { id, userId };
if (tenantId) {
whereClause.tenantId = tenantId;
}
const history = await this.searchHistoryRepository.findOne({
where: whereClause,
relations: ['messages'],
order: { messages: { createdAt: 'ASC' } },
});
if (!history) {
throw new NotFoundException(
this.i18nService.getMessage('conversationHistoryNotFound'),
);
}
return {
id: history.id,
title: history.title,
selectedGroups: history.selectedGroups
? JSON.parse(history.selectedGroups)
: null,
messages: history.messages.map((message) => ({
id: message.id,
role: message.role,
content: message.content,
sources: message.sources ? JSON.parse(message.sources) : undefined,
createdAt: message.createdAt.toISOString(),
})),
};
}
async create(
userId: string,
tenantId: string,
title: string,
selectedGroups?: string[],
): Promise<SearchHistory> {
const history = this.searchHistoryRepository.create({
userId,
tenantId,
title: title.length > 50 ? title.substring(0, 50) + '...' : title,
selectedGroups: selectedGroups
? JSON.stringify(selectedGroups)
: undefined,
});
return await this.searchHistoryRepository.save(history);
}
async addMessage(
historyId: string,
role: 'user' | 'assistant',
content: string,
sources?: any[],
): Promise<ChatMessage> {
const message = this.chatMessageRepository.create({
searchHistoryId: historyId,
role,
content,
sources: sources ? JSON.stringify(sources) : undefined,
});
const savedMessage = await this.chatMessageRepository.save(message);
// Update history record update time
await this.searchHistoryRepository.update(historyId, {
updatedAt: new Date(),
});
return savedMessage;
}
async remove(id: string, userId: string, tenantId: string): Promise<void> {
const history = await this.searchHistoryRepository.findOne({
where: { id, userId, tenantId },
});
if (!history) {
throw new NotFoundException(
this.i18nService.getMessage('conversationHistoryNotFound'),
);
}
await this.searchHistoryRepository.remove(history);
}
async updateTitle(
id: string,
title: string,
tenantId?: string,
): Promise<void> {
const whereClause: any = { id };
if (tenantId) {
whereClause.tenantId = tenantId;
}
await this.searchHistoryRepository.update(whereClause, { title });
}
}