forked from hangshuo652/aurak
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,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user