import { Injectable, Logger, 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 { private readonly logger = new Logger(KnowledgeGroupService.name); constructor( @InjectRepository(KnowledgeGroup) private groupRepository: Repository, @InjectRepository(KnowledgeBase) private knowledgeBaseRepository: Repository, @Inject(forwardRef(() => KnowledgeBaseService)) private knowledgeBaseService: KnowledgeBaseService, private tenantService: TenantService, private i18nService: I18nService, ) {} async findAll( userId: string, tenantId: string, ): Promise { this.logger.log('[KnowledgeGroup findAll] userId: ' + userId + ', tenantId: ' + tenantId); // Return all groups for the tenant with file counts const queryBuilder = this.groupRepository .createQueryBuilder('group') .leftJoin('group.knowledgeBases', 'kb'); if (tenantId === null) { queryBuilder.where('group.tenantId IS NULL'); } else { queryBuilder.where('group.tenantId = :tenantId', { tenantId }); } const groups = await queryBuilder .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(); 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 { 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 { this.logger.log('[KnowledgeGroup create] userId: ' + userId + ', tenantId: ' + tenantId); const group = this.groupRepository.create({ ...createGroupDto, parentId: createGroupDto.parentId ?? null, tenantId, }); const saved = await this.groupRepository.save(group); this.logger.log('[KnowledgeGroup create] saved group tenantId: ' + saved.tenantId); return saved; } async update( id: string, userId: string, tenantId: string, updateGroupDto: UpdateGroupDto, ): Promise { // 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 { 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 { // 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) { this.logger.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 { const group = await this.groupRepository.findOne({ where: { id: groupId }, relations: ['knowledgeBases'], }); if (!group) { throw new NotFoundException(this.i18nService.getMessage('groupNotFound')); } 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`, ); } const allGroups = await this.groupRepository.find({ where: tenantId === null ? {} : { tenantId }, relations: ['knowledgeBases'], }); const childIds = new Set(); const collectDescendantIds = (parentId: string) => { for (const g of allGroups) { if (g.parentId === parentId) { childIds.add(g.id); collectDescendantIds(g.id); } } }; collectDescendantIds(groupId); const result = [...(group.knowledgeBases || [])]; for (const childId of childIds) { const childGroup = allGroups.find(g => g.id === childId); if (childGroup?.knowledgeBases) { result.push(...childGroup.knowledgeBases); } } return result; } async addFilesToGroup( fileId: string, groupIds: string[], userId: string, tenantId: string, ): Promise { 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 { 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 { 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 { const existing = await this.groupRepository.findOne({ where: { name, tenantId, parentId: parentId ?? undefined }, }); if (existing) return existing; return this.create(userId, tenantId, { name, description, parentId }); } }