import { cp, rm, mkdir, stat, readdir } from "fs/promises"; import { existsSync, mkdirSync } from "fs"; import path from "path"; import { prisma } from "./prisma"; const SNAPSHOT_DIR = process.env.SNAPSHOT_DIR || "./snapshots"; const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads"; const FILES_DIR = path.join(UPLOAD_DIR, "files"); const MAX_SNAPSHOTS = 7; if (!existsSync(SNAPSHOT_DIR)) mkdirSync(SNAPSHOT_DIR, { recursive: true }); function getTodayKey(): string { return new Date().toISOString().split("T")[0]; // YYYY-MM-DD } function buildLabel(dateStr: string): string { // dateStr = YYYY-MM-DD, parse in local noon to avoid timezone shifts const [y, m, d] = dateStr.split("-").map(Number); const date = new Date(y, m - 1, d, 12); return date.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", year: "numeric", }); } async function getDirSize(dir: string): Promise { if (!existsSync(dir)) return 0; let total = 0; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { total += await getDirSize(full); } else { const s = await stat(full); total += s.size; } } return total; } export async function createSnapshot(): Promise<{ id: string; snapshotDate: string }> { const snapshotDate = getTodayKey(); const snapshotPath = path.resolve(path.join(SNAPSHOT_DIR, snapshotDate)); const label = buildLabel(snapshotDate); // Upsert the DB record (one per day — re-snapshot replaces the old one) let record = await prisma.snapshot.findUnique({ where: { snapshotDate } }); // Remove existing snapshot dir if present if (existsSync(snapshotPath)) { await rm(snapshotPath, { recursive: true, force: true }); } mkdirSync(snapshotPath, { recursive: true }); if (record) { record = await prisma.snapshot.update({ where: { id: record.id }, data: { status: "pending", error: null, label, path: snapshotPath, createdAt: new Date() }, }); } else { record = await prisma.snapshot.create({ data: { label, snapshotDate, path: snapshotPath, status: "pending", size: BigInt(0) }, }); } try { // Copy uploads/files const filesSnapshot = path.join(snapshotPath, "files"); mkdirSync(filesSnapshot, { recursive: true }); if (existsSync(FILES_DIR)) { await cp(FILES_DIR, filesSnapshot, { recursive: true }); } // Copy SQLite DB const dbPath = path.resolve("./prisma/dev.db"); const dbSnapshotDir = path.join(snapshotPath, "database"); mkdirSync(dbSnapshotDir, { recursive: true }); if (existsSync(dbPath)) { await cp(dbPath, path.join(dbSnapshotDir, "dev.db")); } const size = await getDirSize(snapshotPath); await prisma.snapshot.update({ where: { id: record.id }, data: { status: "completed", size: BigInt(size) }, }); // Keep only the last MAX_SNAPSHOTS days await pruneOldSnapshots(); return { id: record.id, snapshotDate }; } catch (error) { await prisma.snapshot.update({ where: { id: record.id }, data: { status: "failed", error: (error as Error).message }, }); throw error; } } export async function listSnapshots() { const snapshots = await prisma.snapshot.findMany({ orderBy: { snapshotDate: "desc" }, take: MAX_SNAPSHOTS, }); return snapshots.map((s) => ({ ...s, size: Number(s.size) })); } export async function deleteSnapshot(id: string): Promise { const snapshot = await prisma.snapshot.findUnique({ where: { id } }); if (!snapshot) return false; if (existsSync(snapshot.path)) { await rm(snapshot.path, { recursive: true, force: true }); } await prisma.snapshot.delete({ where: { id } }); return true; } export async function restoreSnapshot(id: string): Promise { const snapshot = await prisma.snapshot.findUnique({ where: { id } }); if (!snapshot || snapshot.status !== "completed") { throw new Error("Snapshot introuvable ou incomplet"); } if (!existsSync(snapshot.path)) { throw new Error("Fichiers du snapshot manquants sur le disque"); } const filesSnapshot = path.join(snapshot.path, "files"); const dbSnapshot = path.join(snapshot.path, "database", "dev.db"); // Restore files dir if (existsSync(filesSnapshot)) { if (existsSync(FILES_DIR)) { await rm(FILES_DIR, { recursive: true, force: true }); } await mkdir(FILES_DIR, { recursive: true }); await cp(filesSnapshot, FILES_DIR, { recursive: true }); } // Restore SQLite DB if (existsSync(dbSnapshot)) { const dbPath = path.resolve("./prisma/dev.db"); await prisma.$disconnect(); await cp(dbSnapshot, dbPath); // Prisma reconnects automatically on next query } } async function pruneOldSnapshots(): Promise { const all = await prisma.snapshot.findMany({ orderBy: { snapshotDate: "desc" } }); const toDelete = all.slice(MAX_SNAPSHOTS); for (const snapshot of toDelete) { if (existsSync(snapshot.path)) { await rm(snapshot.path, { recursive: true, force: true }); } await prisma.snapshot.delete({ where: { id: snapshot.id } }); } } /** * Called by the server on an hourly tick. * Creates a snapshot only if none exists for today yet. * Returns true if a snapshot was created. */ export async function createDailySnapshotIfNeeded(): Promise { const today = getTodayKey(); const existing = await prisma.snapshot.findUnique({ where: { snapshotDate: today }, }); if (existing?.status === "completed") return false; await createSnapshot(); return true; }