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,57 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { NoteCategoryService } from './note-category.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@Controller('v1/note-categories')
@UseGuards(CombinedAuthGuard)
export class NoteCategoryController {
constructor(private readonly categoryService: NoteCategoryService) {}
@Get()
async findAll(@Request() req: any) {
return this.categoryService.findAll(req.user.id);
}
@Post()
async create(
@Request() req: any,
@Body('name') name: string,
@Body('parentId') parentId?: string,
) {
return this.categoryService.create(
req.user.id,
name,
parentId,
);
}
@Put(':id')
async update(
@Request() req: any,
@Param('id') id: string,
@Body('name') name?: string,
@Body('parentId') parentId?: string,
) {
return this.categoryService.update(
req.user.id,
id,
name,
parentId,
);
}
@Delete(':id')
async remove(@Request() req: any, @Param('id') id: string) {
return this.categoryService.remove(req.user.id, id);
}
}
+52
View File
@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Note } from './note.entity';
@Entity('note_categories')
export class NoteCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ name: 'parent_id', nullable: true, type: 'text' })
parentId: string;
@Column({ default: 1 })
level: number;
@ManyToOne(() => NoteCategory, (category) => category.children, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'parent_id' })
parent: NoteCategory;
@OneToMany(() => NoteCategory, (category) => category.parent)
children: NoteCategory[];
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@OneToMany(() => Note, (note) => note.category)
notes: Note[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
+109
View File
@@ -0,0 +1,109 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NoteCategory } from './note-category.entity';
import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class NoteCategoryService {
constructor(
@InjectRepository(NoteCategory)
private readonly categoryRepository: Repository<NoteCategory>,
private readonly i18nService: I18nService,
) {}
async findAll(userId: string): Promise<NoteCategory[]> {
return this.categoryRepository.find({
where: { userId },
order: { level: 'ASC', name: 'ASC' },
});
}
async create(
userId: string,
name: string,
parentId?: string,
): Promise<NoteCategory> {
let level = 1;
if (parentId) {
const parent = await this.categoryRepository.findOne({
where: { id: parentId, userId },
});
if (!parent) {
throw new NotFoundException(
this.i18nService.getMessage('parentCategoryNotFound'),
);
}
if (parent.level >= 3) {
throw new Error(
this.i18nService.getMessage('maxCategoryDepthExceeded'),
);
}
level = parent.level + 1;
}
const category = this.categoryRepository.create({
name,
userId,
parentId,
level,
});
return this.categoryRepository.save(category);
}
async update(
userId: string,
id: string,
name?: string,
parentId?: string,
): Promise<NoteCategory> {
const category = await this.categoryRepository.findOne({
where: { id, userId },
});
if (!category) {
throw new NotFoundException(
this.i18nService.getMessage('categoryNotFound'),
);
}
if (name !== undefined) {
category.name = name;
}
if (parentId !== undefined && parentId !== category.parentId) {
if (parentId === null) {
category.parentId = null as any;
category.level = 1;
} else {
const parent = await this.categoryRepository.findOne({
where: { id: parentId, userId },
});
if (!parent)
throw new NotFoundException(
this.i18nService.getMessage('parentCategoryNotFound'),
);
if (parent.level >= 3)
throw new Error(
this.i18nService.getMessage('maxCategoryDepthExceeded'),
);
category.parentId = parentId;
category.level = parent.level + 1;
}
}
return this.categoryRepository.save(category);
}
async remove(userId: string, id: string): Promise<void> {
const result = await this.categoryRepository.delete({
id,
userId,
});
if (result.affected === 0) {
throw new NotFoundException(
this.i18nService.getMessage('categoryNotFound'),
);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Req,
Query,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { NoteService } from './note.service';
import { Note } from './note.entity';
@Controller('notes')
@UseGuards(CombinedAuthGuard)
export class NoteController {
constructor(private readonly noteService: NoteService) {}
@Post()
create(@Req() req, @Body() createNoteDto: Partial<Note>) {
return this.noteService.create(
req.user.id,
createNoteDto,
);
}
@Get()
findAll(
@Req() req,
@Query('groupId') groupId?: string,
@Query('categoryId') categoryId?: string,
) {
return this.noteService.findAll(
req.user.id,
req.user.isAdmin,
groupId,
categoryId,
);
}
@Get(':id')
findOne(@Req() req, @Param('id') id: string) {
return this.noteService.findOne(
req.user.id,
id,
req.user.isAdmin,
);
}
@Patch(':id')
update(
@Req() req,
@Param('id') id: string,
@Body() updateNoteDto: Partial<Note>,
) {
return this.noteService.update(
req.user.id,
id,
updateNoteDto,
req.user.isAdmin,
);
}
@Delete(':id')
remove(@Req() req, @Param('id') id: string) {
return this.noteService.remove(
req.user.id,
id,
req.user.isAdmin,
);
}
@Post('from-pdf-selection')
@UseInterceptors(FileInterceptor('screenshot'))
createFromPDFSelection(
@Req() req,
@UploadedFile() screenshot: Express.Multer.File,
@Body('fileId') fileId: string,
@Body('groupId') groupId?: string,
@Body('categoryId') categoryId?: string,
@Body('pageNumber') pageNumber?: string,
) {
return this.noteService.createFromPDFSelection(
req.user.id,
fileId,
screenshot,
groupId,
categoryId,
pageNumber ? parseInt(pageNumber, 10) : undefined,
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../user/user.entity';
import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
@Entity('notes')
export class Note {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column('text')
content: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ name: 'group_id', nullable: true })
groupId: string; // Corresponds to Notebook/KnowledgeGroup ID
@Column({
type: 'simple-enum',
enum: ['PRIVATE', 'TENANT', 'GLOBAL_PENDING', 'GLOBAL_APPROVED'],
default: 'PRIVATE',
name: 'sharing_status',
})
sharingStatus: string;
@Column({ name: 'screenshot_path', nullable: true })
screenshotPath: string; // Path to screenshot image for PDF selections
@Column({ name: 'source_file_id', nullable: true })
sourceFileId: string; // ID of the source PDF file
@Column({ name: 'source_page_number', nullable: true })
sourcePageNumber: number; // Page number in the source PDF
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => KnowledgeGroup)
@JoinColumn({ name: 'group_id' })
group: KnowledgeGroup;
@Column({ name: 'category_id', nullable: true })
categoryId: string;
@ManyToOne('NoteCategory', 'notes', { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'category_id' })
category: any;
}
+24
View File
@@ -0,0 +1,24 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Note } from './note.entity';
import { NoteCategory } from './note-category.entity';
import { NoteService } from './note.service';
import { NoteController } from './note.controller';
import { NoteCategoryService } from './note-category.service';
import { NoteCategoryController } from './note-category.controller';
import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
import { OcrModule } from '../ocr/ocr.module';
import { I18nModule } from '../i18n/i18n.module';
@Module({
imports: [
TypeOrmModule.forFeature([Note, NoteCategory]),
forwardRef(() => KnowledgeGroupModule),
OcrModule,
I18nModule,
],
providers: [NoteService, NoteCategoryService],
controllers: [NoteController, NoteCategoryController],
exports: [NoteService, NoteCategoryService],
})
export class NoteModule {}
+218
View File
@@ -0,0 +1,218 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Note } from './note.entity';
import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
import { OcrService } from '../ocr/ocr.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class NoteService {
// Directory will be created dynamically per user
private getScreenshotsDir(userId: string) {
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
}
constructor(
@InjectRepository(Note)
private readonly noteRepository: Repository<Note>,
private readonly ocrService: OcrService,
private readonly i18nService: I18nService,
) {}
private async ensureScreenshotsDir(userId: string) {
const dir = this.getScreenshotsDir(userId);
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
async create(
userId: string,
data: Partial<Note>,
): Promise<Note> {
// Handle empty strings for foreign keys
if (data.groupId === '') {
data.groupId = null as any;
}
if (data.categoryId === '') {
data.categoryId = null as any;
}
const note = this.noteRepository.create({
...data,
userId,
});
return this.noteRepository.save(note);
}
async findAll(
userId: string,
isAdmin: boolean,
groupId?: string,
categoryId?: string,
): Promise<Note[]> {
const query = this.noteRepository
.createQueryBuilder('note')
.leftJoinAndSelect('note.user', 'user');
if (!isAdmin) {
query.where('note.userId = :userId', { userId });
}
if (groupId) {
query.andWhere('note.groupId = :groupId', { groupId });
}
if (categoryId) {
query.andWhere('note.categoryId = :categoryId', { categoryId });
}
return query.getMany();
}
async findOne(
userId: string,
id: string,
isAdmin: boolean,
): Promise<Note> {
let note;
if (isAdmin) {
note = await this.noteRepository.findOne({
where: { id },
relations: ['user'],
});
} else {
note = await this.noteRepository.findOne({
where: { id, userId },
relations: ['user'],
});
}
if (!note) {
throw new NotFoundException(
this.i18nService.formatMessage('noteNotFound', { id }),
);
}
return note;
}
async update(
userId: string,
id: string,
data: Partial<Note>,
isAdmin: boolean,
): Promise<Note> {
const note = await this.findOne(userId, id, isAdmin);
// Remove protected fields
delete (data as any).id;
delete (data as any).userId;
delete (data as any).createdAt;
// Handle empty strings for foreign keys
if (data.groupId === '') {
data.groupId = null as any;
}
if (data.categoryId === '') {
data.categoryId = null as any;
}
Object.assign(note, data);
return this.noteRepository.save(note);
}
async createFromPDFSelection(
userId: string,
fileId: string,
screenshot: Express.Multer.File,
groupId?: string,
categoryId?: string,
pageNumber?: number,
): Promise<Note> {
// If groupId is provided, verify that the group exists
// We'll directly query the group to ensure it exists, regardless of user permissions
// Since all groups are accessible to all users anyway
if (groupId) {
const groupRepo =
this.noteRepository.manager.getRepository(KnowledgeGroup);
const group = await groupRepo.findOne({
where: { id: groupId },
});
if (!group) {
throw new NotFoundException(
this.i18nService.formatMessage('knowledgeGroupNotFound', {
id: groupId,
}),
);
}
// Optional: Add logging to help debug permission issues
console.log(`User ${userId} attempting to add note to group ${groupId}`);
}
if (categoryId === '') {
categoryId = null as any;
}
// Save screenshot to disk
await this.ensureScreenshotsDir(userId);
const filename = `${uuidv4()}-${Date.now()}.png`;
const screenshotPath = path.join(
this.getScreenshotsDir(userId),
filename,
);
await fs.writeFile(screenshotPath, screenshot.buffer);
// Extract text using OCR
let extractedText = '';
try {
extractedText = await this.ocrService.extractTextFromImage(
screenshot.buffer,
);
} catch (error) {
console.error('OCR extraction failed:', error);
// Continue without OCR text if extraction fails
}
// Create note with screenshot and extracted text
const note = this.noteRepository.create({
userId,
groupId: groupId || (null as any),
categoryId: categoryId || (null as any),
title: this.i18nService.formatMessage('pdfNoteTitle', {
date: new Date().toLocaleString(),
}),
content: extractedText || this.i18nService.getMessage('noTextExtracted'),
screenshotPath: `notes-screenshots/${userId}/${filename}`,
sourceFileId: fileId,
sourcePageNumber: pageNumber,
});
return this.noteRepository.save(note);
}
async remove(
userId: string,
id: string,
isAdmin: boolean,
): Promise<void> {
let result;
if (isAdmin) {
result = await this.noteRepository.delete({ id });
} else {
result = await this.noteRepository.delete({ id, userId });
}
if (result.affected === 0) {
throw new NotFoundException(
this.i18nService.formatMessage('noteNotFound', { id }),
);
}
}
}