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>
563 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|