/** * FolderService - Gestión de carpetas de almacenamiento * * Servicio para CRUD de carpetas, estructura jerárquica y operaciones de movimiento. * * @module Storage */ import { Repository, IsNull, Not, In } from 'typeorm'; import { StorageFolder } from '../entities/folder.entity'; import { StorageBucket } from '../entities/bucket.entity'; import { StorageFile } from '../entities/file.entity'; export interface CreateFolderDto { bucketId: string; parentId?: string; name: string; description?: string; color?: string; icon?: string; isPrivate?: boolean; } export interface UpdateFolderDto { name?: string; description?: string; color?: string; icon?: string; isPrivate?: boolean; } export interface FolderFilters { bucketId?: string; parentId?: string | null; isPrivate?: boolean; ownerId?: string; search?: string; page?: number; limit?: number; } export interface FolderTreeNode { id: string; name: string; path: string; depth: number; fileCount: number; totalSizeBytes: number; children: FolderTreeNode[]; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export class FolderService { constructor( private readonly folderRepository: Repository, private readonly bucketRepository: Repository, private readonly fileRepository: Repository, ) {} /** * Create a new folder */ async create( tenantId: string, dto: CreateFolderDto, userId?: string, ): Promise { // Validate bucket exists const bucket = await this.bucketRepository.findOne({ where: { id: dto.bucketId, isActive: true }, }); if (!bucket) { throw new Error('Bucket no encontrado o inactivo'); } // Calculate path and depth let parentPath = ''; let depth = 0; if (dto.parentId) { const parent = await this.folderRepository.findOne({ where: { id: dto.parentId, tenantId, bucketId: dto.bucketId }, }); if (!parent) { throw new Error('Carpeta padre no encontrada'); } parentPath = parent.path; depth = parent.depth + 1; } const folderPath = parentPath ? `${parentPath}/${dto.name}` : dto.name; // Check if folder with same path already exists const existing = await this.folderRepository.findOne({ where: { tenantId, bucketId: dto.bucketId, path: folderPath }, }); if (existing) { throw new Error('Ya existe una carpeta con ese nombre en esta ubicación'); } const folder = this.folderRepository.create({ tenantId, bucketId: dto.bucketId, parentId: dto.parentId, name: dto.name, path: folderPath, depth, description: dto.description, color: dto.color, icon: dto.icon, isPrivate: dto.isPrivate || false, ownerId: dto.isPrivate ? userId : undefined, createdBy: userId, }); return this.folderRepository.save(folder); } /** * Find folder by ID */ async findById(tenantId: string, id: string): Promise { return this.folderRepository.findOne({ where: { id, tenantId }, relations: ['bucket', 'parent'], }); } /** * Find folder by path */ async findByPath( tenantId: string, bucketId: string, path: string, ): Promise { return this.folderRepository.findOne({ where: { tenantId, bucketId, path }, relations: ['bucket', 'parent'], }); } /** * Find all folders with filters */ async findAll( tenantId: string, filters: FolderFilters = {}, ): Promise> { const page = filters.page || 1; const limit = Math.min(filters.limit || 50, 200); const skip = (page - 1) * limit; const qb = this.folderRepository .createQueryBuilder('f') .leftJoinAndSelect('f.bucket', 'bucket') .leftJoinAndSelect('f.parent', 'parent') .where('f.tenant_id = :tenantId', { tenantId }); if (filters.bucketId) { qb.andWhere('f.bucket_id = :bucketId', { bucketId: filters.bucketId }); } if (filters.parentId === null) { qb.andWhere('f.parent_id IS NULL'); } else if (filters.parentId) { qb.andWhere('f.parent_id = :parentId', { parentId: filters.parentId }); } if (filters.isPrivate !== undefined) { qb.andWhere('f.is_private = :isPrivate', { isPrivate: filters.isPrivate }); } if (filters.ownerId) { qb.andWhere('f.owner_id = :ownerId', { ownerId: filters.ownerId }); } if (filters.search) { qb.andWhere('(f.name ILIKE :search OR f.description ILIKE :search)', { search: `%${filters.search}%`, }); } qb.orderBy('f.name', 'ASC').skip(skip).take(limit); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Get root folders (no parent) for a bucket */ async getRootFolders( tenantId: string, bucketId: string, ): Promise { return this.folderRepository.find({ where: { tenantId, bucketId, parentId: IsNull() }, order: { name: 'ASC' }, }); } /** * Get children of a folder */ async getChildren( tenantId: string, folderId: string, ): Promise { return this.folderRepository.find({ where: { tenantId, parentId: folderId }, order: { name: 'ASC' }, }); } /** * Get full folder tree for a bucket */ async getTree( tenantId: string, bucketId: string, rootFolderId?: string, ): Promise { // Get all folders in the bucket const allFolders = await this.folderRepository.find({ where: { tenantId, bucketId }, order: { path: 'ASC' }, }); // Build tree structure const folderMap = new Map(); const rootNodes: FolderTreeNode[] = []; // First pass: create all nodes for (const folder of allFolders) { folderMap.set(folder.id, { id: folder.id, name: folder.name, path: folder.path, depth: folder.depth, fileCount: folder.fileCount, totalSizeBytes: Number(folder.totalSizeBytes), children: [], }); } // Second pass: build hierarchy for (const folder of allFolders) { const node = folderMap.get(folder.id); if (!node) continue; if (rootFolderId) { // If we have a root folder ID, start from there if (folder.id === rootFolderId) { rootNodes.push(node); } else if (folder.parentId) { const parentNode = folderMap.get(folder.parentId); if (parentNode) { parentNode.children.push(node); } } } else { // No root folder, start from top-level folders if (!folder.parentId) { rootNodes.push(node); } else { const parentNode = folderMap.get(folder.parentId); if (parentNode) { parentNode.children.push(node); } } } } return rootNodes; } /** * Get folder breadcrumb (path from root to folder) */ async getBreadcrumb( tenantId: string, folderId: string, ): Promise<{ id: string; name: string; path: string }[]> { const folder = await this.findById(tenantId, folderId); if (!folder) { return []; } const breadcrumb: { id: string; name: string; path: string }[] = []; let currentFolder: StorageFolder | null = folder; while (currentFolder) { breadcrumb.unshift({ id: currentFolder.id, name: currentFolder.name, path: currentFolder.path, }); if (currentFolder.parentId) { currentFolder = await this.folderRepository.findOne({ where: { id: currentFolder.parentId, tenantId }, }); } else { currentFolder = null; } } return breadcrumb; } /** * Update folder */ async update( tenantId: string, id: string, dto: UpdateFolderDto, ): Promise { const folder = await this.findById(tenantId, id); if (!folder) { return null; } // If renaming, update path and all descendant paths if (dto.name && dto.name !== folder.name) { const oldPath = folder.path; const newPath = folder.parentId ? `${folder.path.substring(0, folder.path.lastIndexOf('/'))}/${dto.name}` : dto.name; // Update all descendant paths await this.updateDescendantPaths(tenantId, oldPath, newPath); folder.path = newPath; } Object.assign(folder, dto); return this.folderRepository.save(folder); } /** * Move folder to a new parent */ async move( tenantId: string, folderId: string, newParentId: string | null, ): Promise { const folder = await this.findById(tenantId, folderId); if (!folder) { return null; } // Prevent moving to self if (newParentId === folderId) { throw new Error('No se puede mover una carpeta a sí misma'); } // Validate new parent if provided let newParent: StorageFolder | null = null; if (newParentId) { newParent = await this.folderRepository.findOne({ where: { id: newParentId, tenantId, bucketId: folder.bucketId }, }); if (!newParent) { throw new Error('Carpeta destino no encontrada'); } // Prevent moving to a descendant if (newParent.path.startsWith(folder.path + '/')) { throw new Error('No se puede mover una carpeta a una de sus subcarpetas'); } } const oldPath = folder.path; const newPath = newParent ? `${newParent.path}/${folder.name}` : folder.name; const newDepth = newParent ? newParent.depth + 1 : 0; // Check if folder with same name already exists in new location const existing = await this.folderRepository.findOne({ where: { tenantId, bucketId: folder.bucketId, path: newPath, id: Not(folderId), }, }); if (existing) { throw new Error('Ya existe una carpeta con ese nombre en el destino'); } // Update all descendant paths await this.updateDescendantPaths(tenantId, oldPath, newPath); // Update folder folder.parentId = newParentId; folder.path = newPath; folder.depth = newDepth; return this.folderRepository.save(folder); } /** * Delete folder and optionally its contents */ async delete( tenantId: string, id: string, deleteContents: boolean = false, ): Promise { const folder = await this.findById(tenantId, id); if (!folder) { return false; } // Check if folder has contents const hasFiles = await this.fileRepository.count({ where: { tenantId, folderId: id, status: 'active' }, }); const hasSubfolders = await this.folderRepository.count({ where: { tenantId, parentId: id }, }); if ((hasFiles > 0 || hasSubfolders > 0) && !deleteContents) { throw new Error( 'La carpeta no está vacía. Use deleteContents=true para eliminar con contenido.', ); } if (deleteContents) { // Soft delete all files in folder and subfolders const descendantFolderIds = await this.getDescendantIds(tenantId, id); const allFolderIds = [id, ...descendantFolderIds]; await this.fileRepository.update( { tenantId, folderId: In(allFolderIds), status: 'active' }, { status: 'deleted', deletedAt: new Date() }, ); // Delete subfolders for (const folderId of descendantFolderIds.reverse()) { await this.folderRepository.delete({ id: folderId, tenantId }); } } // Delete the folder await this.folderRepository.delete({ id, tenantId }); return true; } /** * Get folder statistics */ async getStats( tenantId: string, folderId: string, ): Promise<{ fileCount: number; subfolderCount: number; totalSizeBytes: number; filesByCategory: Record; }> { const folder = await this.findById(tenantId, folderId); if (!folder) { throw new Error('Carpeta no encontrada'); } // Get all descendant folder IDs including current const descendantIds = await this.getDescendantIds(tenantId, folderId); const allFolderIds = [folderId, ...descendantIds]; const [fileStats, categoryStats] = await Promise.all([ this.fileRepository .createQueryBuilder('f') .select('COUNT(*)', 'count') .addSelect('COALESCE(SUM(f.size_bytes), 0)', 'totalSize') .where('f.tenant_id = :tenantId', { tenantId }) .andWhere('f.folder_id IN (:...folderIds)', { folderIds: allFolderIds }) .andWhere('f.status = :status', { status: 'active' }) .getRawOne(), this.fileRepository .createQueryBuilder('f') .select('f.category', 'category') .addSelect('COUNT(*)', 'count') .where('f.tenant_id = :tenantId', { tenantId }) .andWhere('f.folder_id IN (:...folderIds)', { folderIds: allFolderIds }) .andWhere('f.status = :status', { status: 'active' }) .groupBy('f.category') .getRawMany(), ]); const filesByCategory: Record = {}; for (const row of categoryStats) { filesByCategory[row.category || 'other'] = parseInt(row.count, 10); } return { fileCount: parseInt(fileStats.count, 10), subfolderCount: descendantIds.length, totalSizeBytes: parseInt(fileStats.totalSize, 10), filesByCategory, }; } // ============ Private Helper Methods ============ private async updateDescendantPaths( tenantId: string, oldPath: string, newPath: string, ): Promise { // Update all folders that start with the old path await this.folderRepository .createQueryBuilder() .update(StorageFolder) .set({ path: () => `REPLACE(path, '${oldPath}', '${newPath}')`, depth: () => `depth + ${newPath.split('/').length - oldPath.split('/').length}`, }) .where('tenant_id = :tenantId', { tenantId }) .andWhere('path LIKE :pathPattern', { pathPattern: `${oldPath}/%` }) .execute(); } private async getDescendantIds( tenantId: string, folderId: string, ): Promise { const folder = await this.findById(tenantId, folderId); if (!folder) { return []; } const descendants = await this.folderRepository.find({ where: { tenantId, bucketId: folder.bucketId }, select: ['id', 'path'], }); return descendants .filter((d) => d.path.startsWith(folder.path + '/')) .map((d) => d.id); } }