JavaScript 56.7%
TypeScript 42.6%
1/**2 * Corbeille cohérente : mise à la corbeille (fichiers et dossiers récursifs), restauration fidèle,3 * purge réelle (disque + versions + vignettes), purge automatique 30 jours, utilitaires d'arborescence.4 */5import { existsSync } from "fs";6import { readdir, rm, stat } from "fs/promises";7import path from "path";8import { prisma } from "./prisma";9import { deleteFile, deleteThumbnails, deleteVersion } from "./storage";10import { logActivity } from "./activity";1112export const TRASH_RETENTION_DAYS = 30;1314export interface OriginPath { spaceId: string | null; userId: string | null; names: string[] }1516/** Identifiant d'espace partagé effectif d'un dossier (hérité en remontant l'arborescence). */17export async function getSpaceIdForFolder(folderId: string | null | undefined): Promise<string | null> {18 let cur = folderId ?? null;19 const seen = new Set<string>();20 while (cur && !seen.has(cur)) {21 seen.add(cur);22 const f: { parentId: string | null; sharedSpaceId: string | null } | null =23 await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true, sharedSpaceId: true } });24 if (!f) return null;25 if (f.sharedSpaceId) return f.sharedSpaceId;26 cur = f.parentId;27 }28 return null;29}3031/** Chemin (noms) d'un dossier depuis la racine + espace/propriétaire. */32export async function getOriginPath(folderId: string): Promise<OriginPath | null> {33 const names: string[] = [];34 let spaceId: string | null = null;35 let userId: string | null = null;36 let cur: string | null = folderId;37 const seen = new Set<string>();38 while (cur && !seen.has(cur)) {39 seen.add(cur);40 const f: { name: string; parentId: string | null; sharedSpaceId: string | null; userId: string | null } | null =41 await prisma.folder.findUnique({ where: { id: cur }, select: { name: true, parentId: true, sharedSpaceId: true, userId: true } });42 if (!f) return null;43 names.unshift(f.name);44 if (f.sharedSpaceId && !spaceId) spaceId = f.sharedSpaceId;45 if (f.userId && !userId) userId = f.userId;46 cur = f.parentId;47 }48 return { spaceId, userId, names };49}5051/** Tous les identifiants de dossiers du sous-arbre (racine incluse), en largeur. */52export async function collectFolderTree(rootId: string): Promise<string[]> {53 const ids = [rootId];54 const seen = new Set(ids);55 for (let i = 0; i < ids.length; i++) {56 const kids = await prisma.folder.findMany({ where: { parentId: ids[i] }, select: { id: true } });57 for (const k of kids) if (!seen.has(k.id)) { seen.add(k.id); ids.push(k.id); }58 }59 return ids;60}6162/** Vérifie que `folderId` est `ancestorId` ou l'un de ses descendants. */63export async function isDescendantOf(folderId: string, ancestorId: string): Promise<boolean> {64 let cur: string | null = folderId;65 const seen = new Set<string>();66 while (cur && !seen.has(cur)) {67 if (cur === ancestorId) return true;68 seen.add(cur);69 const f: { parentId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true } });70 cur = f?.parentId ?? null;71 }72 return false;73}7475/** Met des fichiers à la corbeille en mémorisant leur dossier d'origine. */76export async function trashFiles(fileIds: string[], reason = "TRASH"): Promise<number> {77 if (!fileIds.length) return 0;78 const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: null }, select: { id: true, name: true, folderId: true } });79 const pathCache = new Map<string, string | null>();80 const now = new Date();81 for (const f of files) {82 let originPath: string | null = null;83 if (f.folderId) {84 if (!pathCache.has(f.folderId)) {85 const p = await getOriginPath(f.folderId);86 pathCache.set(f.folderId, p ? JSON.stringify(p) : null);87 }88 originPath = pathCache.get(f.folderId) ?? null;89 }90 await prisma.file.update({91 where: { id: f.id },92 data: { deletedAt: now, deletedFromFolderId: f.folderId, deletedFromPath: originPath },93 });94 await logActivity(reason, f.id, `Moved to trash: ${f.name}`);95 }96 return files.length;97}9899/**100 * Supprime un dossier : tous les fichiers du sous-arbre partent à la corbeille (restaurables 30 jours),101 * puis les dossiers sont supprimés. Les fichiers déjà en corbeille dans ce sous-arbre conservent leur origine.102 */103export async function trashFolderRecursive(folderId: string): Promise<{ files: number; folders: number }> {104 const ids = await collectFolderTree(folderId);105 const active = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: null }, select: { id: true } });106 const trashed = await trashFiles(active.map((f) => f.id), "TRASH");107 // Fichiers déjà en corbeille : mémoriser l'origine avant que la cascade ne mette folderId à NULL108 const alreadyTrashed = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: { not: null } }, select: { id: true, folderId: true, deletedFromPath: true } });109 for (const f of alreadyTrashed) {110 if (!f.deletedFromPath && f.folderId) {111 const p = await getOriginPath(f.folderId);112 await prisma.file.update({ where: { id: f.id }, data: { deletedFromFolderId: f.folderId, deletedFromPath: p ? JSON.stringify(p) : null } });113 }114 }115 // Suppression des dossiers (cascade Prisma sur les sous-dossiers ; File.folderId → NULL)116 await prisma.folder.delete({ where: { id: folderId } });117 return { files: trashed, folders: ids.length };118}119120/** Recrée (ou retrouve) une chaîne de dossiers par noms dans un espace / pour un propriétaire. */121async function ensureFolderPath(origin: OriginPath): Promise<string | null> {122 let parentId: string | null = null;123 for (const name of origin.names) {124 const where: Record<string, unknown> = { name, parentId };125 if (parentId === null) {126 // au niveau racine, distinguer par espace127 if (origin.spaceId) where.sharedSpaceId = origin.spaceId; else where.sharedSpaceId = null;128 }129 let folder = await prisma.folder.findFirst({ where });130 if (!folder) {131 folder = await prisma.folder.create({ data: { name, parentId, sharedSpaceId: origin.spaceId, userId: origin.userId } });132 }133 parentId = folder.id;134 }135 return parentId;136}137138/** Restaure des fichiers : dossier d'origine s'il existe, sinon chemin recréé, sinon racine. */139export async function restoreFiles(fileIds: string[]): Promise<number> {140 const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } } });141 for (const f of files) {142 let target: string | null = null;143 const candidate = f.deletedFromFolderId ?? f.folderId;144 if (candidate && (await prisma.folder.findUnique({ where: { id: candidate }, select: { id: true } }))) target = candidate;145 else if (f.deletedFromPath) {146 try { target = await ensureFolderPath(JSON.parse(f.deletedFromPath) as OriginPath); } catch { target = null; }147 }148 await prisma.file.update({ where: { id: f.id }, data: { deletedAt: null, folderId: target, deletedFromFolderId: null, deletedFromPath: null } });149 await logActivity("RESTORE", f.id, `Restored from trash: ${f.name}`);150 }151 return files.length;152}153154/** Suppression définitive : contenu, versions, vignettes, enregistrement. */155export async function purgeFiles(fileIds: string[], log = true): Promise<number> {156 const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } }, include: { versions: true } });157 for (const f of files) {158 try { await deleteFile(f.storagePath); } catch { /* déjà absent */ }159 for (const v of f.versions) { try { await deleteVersion(v.storagePath); } catch { /* ignore */ } }160 await deleteThumbnails(f.id);161 await prisma.file.delete({ where: { id: f.id } });162 if (log) await logActivity("PURGE", null, `Permanently deleted: ${f.name}`);163 }164 return files.length;165}166167/** Purge automatique des fichiers en corbeille depuis plus de `days` jours. */168export async function purgeExpiredTrash(days = TRASH_RETENTION_DAYS): Promise<number> {169 const limit = new Date(Date.now() - days * 86400000);170 const expired = await prisma.file.findMany({ where: { deletedAt: { not: null, lt: limit } }, select: { id: true } });171 if (!expired.length) return 0;172 const n = await purgeFiles(expired.map((f) => f.id), false);173 await logActivity("PURGE", null, `Auto-purge: ${n} fichier(s) en corbeille depuis plus de ${days} jours`);174 return n;175}176177/** Nettoie les dossiers de morceaux d'upload orphelins (uploads interrompus). */178export async function cleanupOrphanChunks(maxAgeHours = 24): Promise<number> {179 const dir = path.join(process.env.UPLOAD_DIR || "./uploads", "chunks");180 if (!existsSync(dir)) return 0;181 let removed = 0;182 const limit = Date.now() - maxAgeHours * 3600000;183 for (const entry of await readdir(dir)) {184 const p = path.join(dir, entry);185 try {186 const s = await stat(p);187 if (s.isDirectory() && s.mtimeMs < limit) { await rm(p, { recursive: true, force: true }); removed++; }188 } catch { /* ignore */ }189 }190 return removed;191}192