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,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
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 {
|
||||
KnowledgeGroupService,
|
||||
CreateGroupDto,
|
||||
UpdateGroupDto,
|
||||
} from './knowledge-group.service';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Controller('knowledge-groups')
|
||||
@UseGuards(CombinedAuthGuard, RolesGuard)
|
||||
export class KnowledgeGroupController {
|
||||
constructor(
|
||||
private readonly groupService: KnowledgeGroupService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req) {
|
||||
// All users can see all groups for their tenant (returns tree structure)
|
||||
return await this.groupService.findAll(req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @Request() req) {
|
||||
return await this.groupService.findOne(id, req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
|
||||
return await this.groupService.create(
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
createGroupDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateGroupDto: UpdateGroupDto,
|
||||
@Request() req,
|
||||
) {
|
||||
return await this.groupService.update(
|
||||
id,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
updateGroupDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async remove(@Param('id') id: string, @Request() req) {
|
||||
await this.groupService.remove(id, req.user.id, req.user.tenantId);
|
||||
return { message: this.i18nService.getMessage('groupDeleted') };
|
||||
}
|
||||
|
||||
@Get(':id/files')
|
||||
async getGroupFiles(@Param('id') id: string, @Request() req) {
|
||||
const files = await this.groupService.getGroupFiles(
|
||||
id,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
return { files };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
|
||||
import { Tenant } from '../tenant/tenant.entity';
|
||||
|
||||
@Entity('knowledge_groups')
|
||||
export class KnowledgeGroup {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ default: '#3B82F6' })
|
||||
color: string;
|
||||
|
||||
// Removed userId field to make groups globally accessible
|
||||
// Tenant scoped: groups are shared within a tenant but isolated across tenants
|
||||
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||
tenantId: string;
|
||||
|
||||
@ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
// Hierarchical parent-child relationship
|
||||
@Column({ name: 'parent_id', nullable: true, type: 'text' })
|
||||
parentId: string | null;
|
||||
|
||||
@ManyToOne(() => KnowledgeGroup, (group) => group.children, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent: KnowledgeGroup;
|
||||
|
||||
@OneToMany(() => KnowledgeGroup, (group) => group.parent)
|
||||
children: KnowledgeGroup[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToMany(() => KnowledgeBase, (kb) => kb.groups)
|
||||
@JoinTable({
|
||||
name: 'knowledge_base_groups',
|
||||
joinColumn: { name: 'group_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: {
|
||||
name: 'knowledge_base_id',
|
||||
referencedColumnName: 'id',
|
||||
},
|
||||
})
|
||||
knowledgeBases: KnowledgeBase[];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KnowledgeGroup } from './knowledge-group.entity';
|
||||
import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
|
||||
import { KnowledgeGroupService } from './knowledge-group.service';
|
||||
import { KnowledgeGroupController } from './knowledge-group.controller';
|
||||
import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
|
||||
import { I18nModule } from '../i18n/i18n.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([KnowledgeGroup, KnowledgeBase]),
|
||||
forwardRef(() => ElasticsearchModule),
|
||||
forwardRef(() => KnowledgeBaseModule),
|
||||
I18nModule,
|
||||
UserModule,
|
||||
TenantModule,
|
||||
],
|
||||
controllers: [KnowledgeGroupController],
|
||||
providers: [KnowledgeGroupService, CombinedAuthGuard],
|
||||
exports: [KnowledgeGroupService],
|
||||
})
|
||||
export class KnowledgeGroupModule {}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { KnowledgeGroup } from './knowledge-group.entity';
|
||||
import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
|
||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
export interface CreateGroupDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateGroupDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface GroupWithFileCount {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
fileCount: number;
|
||||
parentId?: string | null;
|
||||
children?: GroupWithFileCount[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PaginatedGroups {
|
||||
items: GroupWithFileCount[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeGroupService {
|
||||
constructor(
|
||||
@InjectRepository(KnowledgeGroup)
|
||||
private groupRepository: Repository<KnowledgeGroup>,
|
||||
@InjectRepository(KnowledgeBase)
|
||||
private knowledgeBaseRepository: Repository<KnowledgeBase>,
|
||||
@Inject(forwardRef(() => KnowledgeBaseService))
|
||||
private knowledgeBaseService: KnowledgeBaseService,
|
||||
private tenantService: TenantService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<GroupWithFileCount[]> {
|
||||
// Return all groups for the tenant with file counts
|
||||
const groups = await this.groupRepository
|
||||
.createQueryBuilder('group')
|
||||
.leftJoin('group.knowledgeBases', 'kb')
|
||||
.where('group.tenantId = :tenantId', { tenantId })
|
||||
.addSelect('COUNT(kb.id)', 'fileCount')
|
||||
.groupBy('group.id')
|
||||
.orderBy('group.createdAt', 'ASC')
|
||||
.getRawAndEntities();
|
||||
|
||||
const flatList: GroupWithFileCount[] = groups.entities.map(
|
||||
(group, index) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
color: group.color,
|
||||
parentId: group.parentId ?? null,
|
||||
fileCount: parseInt(groups.raw[index].fileCount) || 0,
|
||||
createdAt: group.createdAt,
|
||||
children: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// Build tree structure
|
||||
return this.buildTree(flatList);
|
||||
}
|
||||
|
||||
/** Build a nested tree from a flat list */
|
||||
private buildTree(items: GroupWithFileCount[]): GroupWithFileCount[] {
|
||||
const map = new Map<string, GroupWithFileCount>();
|
||||
items.forEach((item) => map.set(item.id, { ...item, children: [] }));
|
||||
|
||||
const roots: GroupWithFileCount[] = [];
|
||||
map.forEach((item) => {
|
||||
if (item.parentId && map.has(item.parentId)) {
|
||||
map.get(item.parentId)!.children!.push(item);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
async findOne(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<KnowledgeGroup> {
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['knowledgeBases'],
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||
}
|
||||
|
||||
// Check permission using TenantService
|
||||
const hasAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
group.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to access this knowledge group`,
|
||||
);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
createGroupDto: CreateGroupDto,
|
||||
): Promise<KnowledgeGroup> {
|
||||
const group = this.groupRepository.create({
|
||||
...createGroupDto,
|
||||
parentId: createGroupDto.parentId ?? null,
|
||||
tenantId,
|
||||
});
|
||||
|
||||
return await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
updateGroupDto: UpdateGroupDto,
|
||||
): Promise<KnowledgeGroup> {
|
||||
// Update group within the tenant
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||
}
|
||||
|
||||
Object.assign(group, updateGroupDto);
|
||||
return await this.groupRepository.save(group);
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<void> {
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||
}
|
||||
|
||||
// Recursively delete this group and all its descendants
|
||||
await this.removeGroupRecursive(id, userId, tenantId);
|
||||
}
|
||||
|
||||
/** Recursively delete a group, all its children, and all associated files */
|
||||
private async removeGroupRecursive(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<void> {
|
||||
// 1. Find all direct children of this group
|
||||
const children = await this.groupRepository.find({
|
||||
where: { parentId: id, tenantId },
|
||||
});
|
||||
|
||||
// 2. Recurse into each child first (depth-first)
|
||||
for (const child of children) {
|
||||
await this.removeGroupRecursive(child.id, userId, tenantId);
|
||||
}
|
||||
|
||||
// 3. Delete all files belonging to this group
|
||||
const files = await this.knowledgeBaseRepository
|
||||
.createQueryBuilder('kb')
|
||||
.innerJoin('kb.groups', 'group')
|
||||
.where('group.id = :groupId', { groupId: id })
|
||||
.select('kb.id')
|
||||
.getMany();
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const fullFile = await this.knowledgeBaseRepository.findOne({
|
||||
where: { id: file.id },
|
||||
select: ['id', 'userId', 'tenantId'],
|
||||
});
|
||||
if (fullFile) {
|
||||
await this.knowledgeBaseService.deleteFile(
|
||||
fullFile.id,
|
||||
fullFile.userId,
|
||||
fullFile.tenantId,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete file ${file.id} when deleting group ${id}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete the group itself
|
||||
const group = await this.groupRepository.findOne({ where: { id } });
|
||||
if (group) {
|
||||
await this.groupRepository.remove(group);
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupFiles(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<KnowledgeBase[]> {
|
||||
const group = await this.groupRepository.findOne({
|
||||
where: { id: groupId },
|
||||
relations: ['knowledgeBases'],
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||
}
|
||||
|
||||
// Check permission using TenantService
|
||||
const hasAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
group.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to access this knowledge group`,
|
||||
);
|
||||
}
|
||||
|
||||
return group.knowledgeBases;
|
||||
}
|
||||
|
||||
async addFilesToGroup(
|
||||
fileId: string,
|
||||
groupIds: string[],
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<void> {
|
||||
const file = await this.knowledgeBaseRepository.findOne({
|
||||
where: { id: fileId },
|
||||
relations: ['groups'],
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
|
||||
}
|
||||
|
||||
// Check permission for file
|
||||
const fileAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
file.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!fileAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to modify this knowledge base`,
|
||||
);
|
||||
}
|
||||
|
||||
// Load all groups by ID without user restriction
|
||||
const groups = await this.groupRepository.findBy({ id: In(groupIds) });
|
||||
// Verify each group access
|
||||
for (const g of groups) {
|
||||
const gAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
g.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!gAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to access group ${g.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupIds.length > 0 && groups.length !== groupIds.length) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getMessage('someGroupsNotFound'),
|
||||
);
|
||||
}
|
||||
|
||||
file.groups = groups;
|
||||
await this.knowledgeBaseRepository.save(file);
|
||||
}
|
||||
|
||||
async removeFileFromGroup(
|
||||
fileId: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<void> {
|
||||
const file = await this.knowledgeBaseRepository.findOne({
|
||||
where: { id: fileId },
|
||||
relations: ['groups'],
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
|
||||
}
|
||||
|
||||
// Check permission for file
|
||||
const fileAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
file.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!fileAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to modify this knowledge base`,
|
||||
);
|
||||
}
|
||||
|
||||
file.groups = file.groups.filter((group) => group.id !== groupId);
|
||||
await this.knowledgeBaseRepository.save(file);
|
||||
}
|
||||
|
||||
async getFileIdsByGroups(
|
||||
groupIds: string[],
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<string[]> {
|
||||
if (!groupIds || groupIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Security check: Verify user has access to these groups
|
||||
const groups = await this.groupRepository.findBy({ id: In(groupIds) });
|
||||
for (const g of groups) {
|
||||
const hasAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
g.tenantId,
|
||||
tenantId,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException(
|
||||
`You do not have permission to access group ${g.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.knowledgeBaseRepository
|
||||
.createQueryBuilder('kb')
|
||||
.innerJoin('kb.groups', 'group')
|
||||
.where('group.id IN (:...groupIds)', { groupIds })
|
||||
// No extra tenantId check here because we verified the groups above
|
||||
.select('DISTINCT kb.id', 'id')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a group by name and parentId within a tenant.
|
||||
* Used by import tasks to build folder hierarchy.
|
||||
*/
|
||||
async findOrCreate(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
description?: string,
|
||||
): Promise<KnowledgeGroup> {
|
||||
const existing = await this.groupRepository.findOne({
|
||||
where: { name, tenantId, parentId: parentId ?? undefined },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.create(userId, tenantId, { name, description, parentId });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user