/** * Corbeille cohérente : mise à la corbeille (fichiers et dossiers récursifs), restauration fidèle, * purge réelle (disque + versions + vignettes), purge automatique 30 jours, utilitaires d'arborescence. */ import { existsSync } from "fs"; import { readdir, rm, stat } from "fs/promises"; import path from "path"; import { prisma } from "./prisma"; import { deleteFile, deleteThumbnails, deleteVersion } from "./storage"; import { logActivity } from "./activity"; export const TRASH_RETENTION_DAYS = 30; export interface OriginPath { spaceId: string | null; userId: string | null; names: string[] } /** Identifiant d'espace partagé effectif d'un dossier (hérité en remontant l'arborescence). */ export async function getSpaceIdForFolder(folderId: string | null | undefined): Promise { let cur = folderId ?? null; const seen = new Set(); while (cur && !seen.has(cur)) { seen.add(cur); const f: { parentId: string | null; sharedSpaceId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true, sharedSpaceId: true } }); if (!f) return null; if (f.sharedSpaceId) return f.sharedSpaceId; cur = f.parentId; } return null; } /** Chemin (noms) d'un dossier depuis la racine + espace/propriétaire. */ export async function getOriginPath(folderId: string): Promise { const names: string[] = []; let spaceId: string | null = null; let userId: string | null = null; let cur: string | null = folderId; const seen = new Set(); while (cur && !seen.has(cur)) { seen.add(cur); const f: { name: string; parentId: string | null; sharedSpaceId: string | null; userId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { name: true, parentId: true, sharedSpaceId: true, userId: true } }); if (!f) return null; names.unshift(f.name); if (f.sharedSpaceId && !spaceId) spaceId = f.sharedSpaceId; if (f.userId && !userId) userId = f.userId; cur = f.parentId; } return { spaceId, userId, names }; } /** Tous les identifiants de dossiers du sous-arbre (racine incluse), en largeur. */ export async function collectFolderTree(rootId: string): Promise { const ids = [rootId]; const seen = new Set(ids); for (let i = 0; i < ids.length; i++) { const kids = await prisma.folder.findMany({ where: { parentId: ids[i] }, select: { id: true } }); for (const k of kids) if (!seen.has(k.id)) { seen.add(k.id); ids.push(k.id); } } return ids; } /** Vérifie que `folderId` est `ancestorId` ou l'un de ses descendants. */ export async function isDescendantOf(folderId: string, ancestorId: string): Promise { let cur: string | null = folderId; const seen = new Set(); while (cur && !seen.has(cur)) { if (cur === ancestorId) return true; seen.add(cur); const f: { parentId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true } }); cur = f?.parentId ?? null; } return false; } /** Met des fichiers à la corbeille en mémorisant leur dossier d'origine. */ export async function trashFiles(fileIds: string[], reason = "TRASH"): Promise { if (!fileIds.length) return 0; const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: null }, select: { id: true, name: true, folderId: true } }); const pathCache = new Map(); const now = new Date(); for (const f of files) { let originPath: string | null = null; if (f.folderId) { if (!pathCache.has(f.folderId)) { const p = await getOriginPath(f.folderId); pathCache.set(f.folderId, p ? JSON.stringify(p) : null); } originPath = pathCache.get(f.folderId) ?? null; } await prisma.file.update({ where: { id: f.id }, data: { deletedAt: now, deletedFromFolderId: f.folderId, deletedFromPath: originPath }, }); await logActivity(reason, f.id, `Moved to trash: ${f.name}`); } return files.length; } /** * Supprime un dossier : tous les fichiers du sous-arbre partent à la corbeille (restaurables 30 jours), * puis les dossiers sont supprimés. Les fichiers déjà en corbeille dans ce sous-arbre conservent leur origine. */ export async function trashFolderRecursive(folderId: string): Promise<{ files: number; folders: number }> { const ids = await collectFolderTree(folderId); const active = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: null }, select: { id: true } }); const trashed = await trashFiles(active.map((f) => f.id), "TRASH"); // Fichiers déjà en corbeille : mémoriser l'origine avant que la cascade ne mette folderId à NULL const alreadyTrashed = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: { not: null } }, select: { id: true, folderId: true, deletedFromPath: true } }); for (const f of alreadyTrashed) { if (!f.deletedFromPath && f.folderId) { const p = await getOriginPath(f.folderId); await prisma.file.update({ where: { id: f.id }, data: { deletedFromFolderId: f.folderId, deletedFromPath: p ? JSON.stringify(p) : null } }); } } // Suppression des dossiers (cascade Prisma sur les sous-dossiers ; File.folderId → NULL) await prisma.folder.delete({ where: { id: folderId } }); return { files: trashed, folders: ids.length }; } /** Recrée (ou retrouve) une chaîne de dossiers par noms dans un espace / pour un propriétaire. */ async function ensureFolderPath(origin: OriginPath): Promise { let parentId: string | null = null; for (const name of origin.names) { const where: Record = { name, parentId }; if (parentId === null) { // au niveau racine, distinguer par espace if (origin.spaceId) where.sharedSpaceId = origin.spaceId; else where.sharedSpaceId = null; } let folder = await prisma.folder.findFirst({ where }); if (!folder) { folder = await prisma.folder.create({ data: { name, parentId, sharedSpaceId: origin.spaceId, userId: origin.userId } }); } parentId = folder.id; } return parentId; } /** Restaure des fichiers : dossier d'origine s'il existe, sinon chemin recréé, sinon racine. */ export async function restoreFiles(fileIds: string[]): Promise { const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } } }); for (const f of files) { let target: string | null = null; const candidate = f.deletedFromFolderId ?? f.folderId; if (candidate && (await prisma.folder.findUnique({ where: { id: candidate }, select: { id: true } }))) target = candidate; else if (f.deletedFromPath) { try { target = await ensureFolderPath(JSON.parse(f.deletedFromPath) as OriginPath); } catch { target = null; } } await prisma.file.update({ where: { id: f.id }, data: { deletedAt: null, folderId: target, deletedFromFolderId: null, deletedFromPath: null } }); await logActivity("RESTORE", f.id, `Restored from trash: ${f.name}`); } return files.length; } /** Suppression définitive : contenu, versions, vignettes, enregistrement. */ export async function purgeFiles(fileIds: string[], log = true): Promise { const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } }, include: { versions: true } }); for (const f of files) { try { await deleteFile(f.storagePath); } catch { /* déjà absent */ } for (const v of f.versions) { try { await deleteVersion(v.storagePath); } catch { /* ignore */ } } await deleteThumbnails(f.id); await prisma.file.delete({ where: { id: f.id } }); if (log) await logActivity("PURGE", null, `Permanently deleted: ${f.name}`); } return files.length; } /** Purge automatique des fichiers en corbeille depuis plus de `days` jours. */ export async function purgeExpiredTrash(days = TRASH_RETENTION_DAYS): Promise { const limit = new Date(Date.now() - days * 86400000); const expired = await prisma.file.findMany({ where: { deletedAt: { not: null, lt: limit } }, select: { id: true } }); if (!expired.length) return 0; const n = await purgeFiles(expired.map((f) => f.id), false); await logActivity("PURGE", null, `Auto-purge: ${n} fichier(s) en corbeille depuis plus de ${days} jours`); return n; } /** Nettoie les dossiers de morceaux d'upload orphelins (uploads interrompus). */ export async function cleanupOrphanChunks(maxAgeHours = 24): Promise { const dir = path.join(process.env.UPLOAD_DIR || "./uploads", "chunks"); if (!existsSync(dir)) return 0; let removed = 0; const limit = Date.now() - maxAgeHours * 3600000; for (const entry of await readdir(dir)) { const p = path.join(dir, entry); try { const s = await stat(p); if (s.isDirectory() && s.mtimeMs < limit) { await rm(p, { recursive: true, force: true }); removed++; } } catch { /* ignore */ } } return removed; }