import { spawn } from "child_process"; import http from "http"; import https from "https"; import { URL } from "url"; import path from "path"; import fs from "fs"; import os from "os"; import { prisma } from "@repo/db/client"; const UPLOADS_DIR = path.resolve(process.cwd(), "uploads"); const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations"); async function applyMissingMigrations() { let folders: string[]; try { folders = fs.readdirSync(MIGRATIONS_DIR) .filter((name) => fs.statSync(path.join(MIGRATIONS_DIR, name)).isDirectory()) .sort(); } catch { console.warn("Could not read migrations directory, skipping post-sync migration."); return; } let applied: Set; try { const rows = await prisma.$queryRaw<{ migration_name: string }[]>` SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL `; applied = new Set(rows.map((r: { migration_name: string }) => r.migration_name)); } catch { applied = new Set(); } for (const folder of folders) { if (applied.has(folder)) continue; const sqlFile = path.join(MIGRATIONS_DIR, folder, "migration.sql"); if (!fs.existsSync(sqlFile)) continue; const sql = fs.readFileSync(sqlFile, "utf8"); try { await prisma.$executeRawUnsafe(sql); console.log(`Applied migration: ${folder}`); } catch (err: any) { console.warn(`Migration ${folder} had errors (may already be applied):`, err.message); } } } export function runNetworkSync(sourceUrl: string, apiKey: string): Promise { return new Promise((resolve, reject) => { let targetUrl: URL; try { targetUrl = new URL("/api/database-management/network-backup", sourceUrl); } catch { return reject(new Error(`Invalid source URL: ${sourceUrl}`)); } const client = targetUrl.protocol === "https:" ? https : http; const req = client.get( targetUrl.href, { headers: { "x-network-backup-key": apiKey } }, async (res) => { if (res.statusCode !== 200) { res.resume(); return reject( new Error(`Source returned HTTP ${res.statusCode}: ${res.statusMessage}`) ); } // Drop and recreate the public schema try { await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`); await prisma.$executeRawUnsafe(`CREATE SCHEMA public`); } catch (err: any) { res.destroy(); return reject(new Error(`Failed to reset schema: ${err.message}`)); } const psql = spawn( "psql", [ "-h", process.env.DB_HOST || "localhost", "-U", process.env.DB_USER || "postgres", process.env.DB_NAME || "dental_db", ], { env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD } } ); let stderr = ""; psql.stderr.on("data", (d) => (stderr += d.toString())); psql.on("error", (err) => reject(new Error(`Failed to start psql: ${err.message}`))); psql.on("close", async (code) => { if (code !== 0) { return reject(new Error(`psql exited ${code}: ${stderr}`)); } try { await prisma.$disconnect(); await prisma.$connect(); } catch (_) {} try { await applyMissingMigrations(); } catch (err) { console.error("applyMissingMigrations failed after network sync:", err); } resolve(); }); res.pipe(psql.stdin); } ); req.on("error", (err) => reject(new Error(`Network request failed: ${err.message}`))); req.setTimeout(120_000, () => { req.destroy(); reject(new Error("Network backup request timed out after 120s")); }); }); } export function runNetworkFilesSync(sourceUrl: string, apiKey: string): Promise { return new Promise((resolve, reject) => { let targetUrl: URL; try { targetUrl = new URL("/api/database-management/network-backup-files", sourceUrl); } catch { return reject(new Error(`Invalid source URL: ${sourceUrl}`)); } const client = targetUrl.protocol === "https:" ? https : http; const tmpZip = path.join(os.tmpdir(), `network_files_${Date.now()}.zip`); const tmpFile = fs.createWriteStream(tmpZip); const req = client.get( targetUrl.href, { headers: { "x-network-backup-key": apiKey } }, (res) => { if (res.statusCode !== 200) { res.resume(); tmpFile.close(() => { try { fs.unlinkSync(tmpZip); } catch {} }); return reject(new Error(`Source returned HTTP ${res.statusCode}: ${res.statusMessage}`)); } res.pipe(tmpFile); tmpFile.on("finish", () => { tmpFile.close(() => { // Clear all three upload subfolders, then extract fresh copy for (const sub of ["cloud-storage", "patients", "patient-documents"]) { const subDir = path.join(UPLOADS_DIR, sub); if (fs.existsSync(subDir)) { fs.rmSync(subDir, { recursive: true, force: true }); } fs.mkdirSync(subDir, { recursive: true }); } const unzip = spawn("unzip", ["-o", tmpZip, "-d", UPLOADS_DIR]); let stderr = ""; unzip.stderr.on("data", (d) => (stderr += d.toString())); unzip.on("error", (err) => { try { fs.unlinkSync(tmpZip); } catch {} reject(new Error(`Failed to extract uploads zip: ${err.message}`)); }); unzip.on("close", (code) => { try { fs.unlinkSync(tmpZip); } catch {} if (code !== 0) { return reject(new Error(`unzip exited ${code}: ${stderr}`)); } resolve(); }); }); }); tmpFile.on("error", (err) => { try { fs.unlinkSync(tmpZip); } catch {} reject(new Error(`Failed to write temp zip: ${err.message}`)); }); } ); req.on("error", (err) => { try { fs.unlinkSync(tmpZip); } catch {} reject(new Error(`Network request failed: ${err.message}`)); }); req.setTimeout(300_000, () => { req.destroy(); reject(new Error("File sync request timed out after 5 minutes")); }); }); }