erp-construccion-backend-v2/src/modules/storage/services/folder.service.ts
Adrian Flores Cortes ebc526acb2 [REMEDIATION] feat: Backend remediation - auth controllers, construction entities, storage services
Add 5 auth controllers (device, MFA, permission, role, session), 18 construction entities,
5 storage services, 2 document services. Enhance auth middleware, fix budget/construction
controllers. Addresses gaps from TASK-2026-02-05 analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:18:17 -06:00

563 lines
15 KiB
TypeScript

/**
* 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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class FolderService {
constructor(
private readonly folderRepository: Repository<StorageFolder>,
private readonly bucketRepository: Repository<StorageBucket>,
private readonly fileRepository: Repository<StorageFile>,
) {}
/**
* Create a new folder
*/
async create(
tenantId: string,
dto: CreateFolderDto,
userId?: string,
): Promise<StorageFolder> {
// 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<StorageFolder | null> {
return this.folderRepository.findOne({
where: { id, tenantId },
relations: ['bucket', 'parent'],
});
}
/**
* Find folder by path
*/
async findByPath(
tenantId: string,
bucketId: string,
path: string,
): Promise<StorageFolder | null> {
return this.folderRepository.findOne({
where: { tenantId, bucketId, path },
relations: ['bucket', 'parent'],
});
}
/**
* Find all folders with filters
*/
async findAll(
tenantId: string,
filters: FolderFilters = {},
): Promise<PaginatedResult<StorageFolder>> {
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<StorageFolder[]> {
return this.folderRepository.find({
where: { tenantId, bucketId, parentId: IsNull() },
order: { name: 'ASC' },
});
}
/**
* Get children of a folder
*/
async getChildren(
tenantId: string,
folderId: string,
): Promise<StorageFolder[]> {
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<FolderTreeNode[]> {
// 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<string, FolderTreeNode>();
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<StorageFolder | null> {
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<StorageFolder | null> {
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<boolean> {
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<string, number>;
}> {
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<string, number> = {};
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<void> {
// 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<string[]> {
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);
}
}