import readline from 'readline'; import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import { getProvider, closeStore } from '../src/db/store.js'; import env from '../src/config/env.js'; function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } async function getAllDescendantCategoryIds(provider, rootCategoryId) { if (!rootCategoryId) return []; const catIds = [rootCategoryId]; let queue = [rootCategoryId]; while (queue.length > 0) { const placeholders = queue.map((_, i) => `@p${i}`).join(','); const params = Object.fromEntries(queue.map((id, i) => [`p${i}`, id])); const res = await provider.query( `SELECT id FROM category WHERE parent_id IN (${placeholders})`, params ); queue = res.rows.map((r) => r.id); catIds.push(...queue); } return catIds; } /** * Sweeps the storage directory for image files on disk not linked to any pic record. */ async function cleanStorageDiskFiles(provider) { const activePics = await provider.query('SELECT hash FROM pic'); const validHashes = new Set(activePics.rows.map((r) => r.hash)); const storageDir = path.resolve(process.cwd(), 'data/storage'); const subdirs = ['fullbinary', 'thumbbinary', 'thumbavif']; let removedFiles = 0; for (const sub of subdirs) { const dirPath = path.join(storageDir, sub); if (!fs.existsSync(dirPath)) continue; const files = fs.readdirSync(dirPath); for (const file of files) { const hash = file.replace(/\.[^.]+$/, ''); if (!validHashes.has(hash)) { try { fs.unlinkSync(path.join(dirPath, file)); removedFiles++; } catch {} } } } return removedFiles; } async function wipeCompany(companyIdentifier, autoConfirm = false) { const provider = await getProvider(); const isSqlite = env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3'; // 1. Locate company let compRes; const numId = Number(companyIdentifier); if (Number.isInteger(numId) && numId > 0) { compRes = await provider.query('SELECT id, name, root_category_id FROM company WHERE id = @id', { id: numId }); } else { compRes = await provider.query('SELECT id, name, root_category_id FROM company WHERE name = @name', { name: companyIdentifier }); } if (!compRes.rows[0]) { console.error(chalk.red(`\nCompany "${companyIdentifier}" not found.\n`)); const allCompanies = await provider.query('SELECT id, name FROM company ORDER BY id'); console.log(chalk.yellow('Existing companies:')); for (const c of allCompanies.rows) { console.log(` - [ID: ${c.id}] ${c.name}`); } process.exit(1); } const company = compRes.rows[0]; const companyId = company.id; const companyName = company.name; const rootCatId = company.root_category_id; console.log(chalk.bold.yellow(`\n⚠️ WARNING: You are about to completely wipe company:`)); console.log(` ID: ${chalk.bold.white(companyId)}`); console.log(` Name: ${chalk.bold.white(companyName)}\n`); if (!autoConfirm) { const confirmation = await prompt( chalk.red(`To proceed, type the exact company name "${companyName}": `) ); if (confirmation !== companyName) { console.log(chalk.gray('Aborted. Nothing was deleted.')); await closeStore(); process.exit(0); } } console.log(chalk.cyan(`\nStarting wipe for "${companyName}" (ID: ${companyId})...`)); // 2. Collect candidate category IDs const categoryIds = await getAllDescendantCategoryIds(provider, rootCatId); console.log(` Found ${categoryIds.length} categories in company tree.`); // 3. Begin deletion in dependency order await provider.transaction(async (tx) => { if (isSqlite) { await tx.query('PRAGMA defer_foreign_keys = ON'); } // Wipe google_auth users who had this company selected or belonged to it const authRes = await tx.query(` DELETE FROM google_auth WHERE selected_company = @companyId OR id IN (SELECT google_auth_id FROM companyuser WHERE company_id = @companyId) `, { companyId }); console.log(` Deleted ${authRes.rowsAffected || 0} associated google_auth user(s).`); // Manifest, Attachments, Warehouse & Ledger await tx.query(`DELETE FROM manifestattachment WHERE manifest_id IN (SELECT id FROM manifest WHERE company_id = @companyId)`, { companyId }); await tx.query(`DELETE FROM ledger WHERE warehouse_id IN (SELECT id FROM warehouse WHERE company_id = @companyId)`, { companyId }); await tx.query(`DELETE FROM ledger WHERE manifestarticle_id IN (SELECT ma.id FROM manifestarticle ma JOIN manifest m ON m.id = ma.manifest_id WHERE m.company_id = @companyId)`, { companyId }); await tx.query(`DELETE FROM manifestarticle WHERE manifest_id IN (SELECT id FROM manifest WHERE company_id = @companyId)`, { companyId }); await tx.query('DELETE FROM warehouse WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM manifest WHERE company_id = @companyId', { companyId }); // Hardware / Settings / History await tx.query(`DELETE FROM posterminalcategory WHERE posterminal_id IN (SELECT id FROM posterminal WHERE company_id = @companyId)`, { companyId }); await tx.query('DELETE FROM posterminal WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM printer WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM labeltemplate WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM companysetting WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM companyhistory WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM numbers WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM modelvfs WHERE company_id = @companyId', { companyId }); await tx.query(`DELETE FROM modelconversation WHERE companyuser_id IN (SELECT id FROM companyuser WHERE company_id = @companyId)`, { companyId }); // Articles & Sub-tables const artCount = (await tx.query('SELECT COUNT(*) as cnt FROM article WHERE company_id = @companyId', { companyId })).rows[0].cnt; await tx.query('DELETE FROM articleean WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articletext WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articlepic WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articlehistory WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM price WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articlesupplier WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articleproperty WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articleattributevalue WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM articlelistarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM optionslistarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM categoryarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM article WHERE company_id = @companyId', { companyId }); console.log(` Deleted ${artCount} articles and all child rows.`); // Clean orphaned optionslist and articlelist const optRes = await tx.query(` DELETE FROM optionslist WHERE id NOT IN (SELECT optionslist_id FROM article WHERE optionslist_id IS NOT NULL) `); const artListRes = await tx.query(` DELETE FROM articlelist WHERE id NOT IN (SELECT articlelist_id FROM article WHERE articlelist_id IS NOT NULL) `); console.log(` Cleaned ${optRes.rowsAffected || 0} optionslist and ${artListRes.rowsAffected || 0} articlelist entries.`); // Brand & Unit await tx.query('DELETE FROM brandpic WHERE brand_id IN (SELECT id FROM brand WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM brand WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM unittext WHERE unit_id IN (SELECT id FROM unit WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM unit WHERE company_id = @companyId', { companyId }); // Properties & Attributes defined by company await tx.query('DELETE FROM propertyname WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM attributename WHERE company_id = @companyId', { companyId }); // Category tree if (categoryIds.length > 0) { const catIn = categoryIds.join(','); await tx.query(`DELETE FROM categoryarticle WHERE category_id IN (${catIn})`); await tx.query(`DELETE FROM categorypic WHERE category_id IN (${catIn})`); await tx.query(`DELETE FROM categorytext WHERE category_id IN (${catIn})`); await tx.query(`DELETE FROM categoryproperty WHERE category_id IN (${catIn})`); await tx.query(`DELETE FROM categoryattributevalue WHERE category_id IN (${catIn})`); // Detach root_category_id from company first await tx.query('UPDATE company SET root_category_id = NULL WHERE id = @companyId', { companyId }); // Delete categories from leaf to root const sortedDescCats = [...categoryIds].reverse(); for (const catId of sortedDescCats) { await tx.query('DELETE FROM category WHERE id = @catId', { catId }); } console.log(` Deleted ${categoryIds.length} categories.`); } // Entities & Customers await tx.query('DELETE FROM entityaddress WHERE entity_id IN (SELECT id FROM entity WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM entitycontact WHERE entity_id IN (SELECT id FROM entity WHERE company_id = @companyId)', { companyId }); await tx.query('DELETE FROM entity WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM customergroup WHERE company_id = @companyId', { companyId }); // Company User & Company itself await tx.query('DELETE FROM companyuser WHERE company_id = @companyId', { companyId }); await tx.query('DELETE FROM company WHERE id = @companyId', { companyId }); console.log(` Deleted company record.`); }); // 4. Clean up ALL orphaned pictures across the database const orphanedPics = await provider.query(` SELECT id, hash FROM pic WHERE id NOT IN (SELECT pic_id FROM articlepic) AND id NOT IN (SELECT pic_id FROM categorypic) AND id NOT IN (SELECT pic_id FROM brandpic) `); if (orphanedPics.rows.length > 0) { const orphanIds = orphanedPics.rows.map((r) => r.id).join(','); await provider.query(`DELETE FROM pic WHERE id IN (${orphanIds})`); console.log(` Deleted ${orphanedPics.rows.length} orphaned rows from pic table.`); } // 5. Clean up files on disk const removedFiles = await cleanStorageDiskFiles(provider); if (removedFiles > 0) { console.log(` Removed ${removedFiles} orphaned image files from data/storage/`); } // 6. Remove data/vfs/ and data/storage/vfs/ const vfsDir = path.resolve(process.cwd(), 'data/vfs', String(companyId)); if (fs.existsSync(vfsDir)) { fs.rmSync(vfsDir, { recursive: true, force: true }); console.log(` Removed VFS directory at data/vfs/${companyId}`); } const storageVfsDir = path.resolve(process.cwd(), 'data/storage/vfs', String(companyId)); if (fs.existsSync(storageVfsDir)) { fs.rmSync(storageVfsDir, { recursive: true, force: true }); console.log(` Removed VFS storage directory at data/storage/vfs/${companyId}`); } // 7. Remove import directory from disk if exists const importDir = path.resolve(process.cwd(), 'data/import', String(companyId)); if (fs.existsSync(importDir)) { fs.rmSync(importDir, { recursive: true, force: true }); console.log(` Removed import files at data/import/${companyId}`); } console.log(chalk.green(`\n✔ Company "${companyName}" (ID: ${companyId}) completely wiped.\n`)); await closeStore(); } /** * Clean all orphaned storage, vfs, and pic records across the entire database. */ async function cleanAllOrphans() { const provider = await getProvider(); console.log(chalk.cyan('\nCleaning all orphaned pics, optionslists, articlelists, and storage files...')); const remainingCompanies = await provider.query('SELECT COUNT(*) as cnt FROM company'); const compCount = remainingCompanies.rows[0].cnt; // Delete orphaned pics const orphanedPics = await provider.query(` SELECT id, hash FROM pic WHERE id NOT IN (SELECT pic_id FROM articlepic) AND id NOT IN (SELECT pic_id FROM categorypic) AND id NOT IN (SELECT pic_id FROM brandpic) `); if (orphanedPics.rows.length > 0) { const orphanIds = orphanedPics.rows.map((r) => r.id).join(','); await provider.query(`DELETE FROM pic WHERE id IN (${orphanIds})`); console.log(` Deleted ${orphanedPics.rows.length} orphaned rows from pic table.`); } // Delete orphaned optionslist and articlelist const optRes = await provider.query(` DELETE FROM optionslist WHERE id NOT IN (SELECT optionslist_id FROM article WHERE optionslist_id IS NOT NULL) `); const artListRes = await provider.query(` DELETE FROM articlelist WHERE id NOT IN (SELECT articlelist_id FROM article WHERE articlelist_id IS NOT NULL) `); console.log(` Cleaned ${optRes.rowsAffected || 0} optionslist and ${artListRes.rowsAffected || 0} articlelist rows.`); // Wipe google_auth users who have no selected_company or point to nonexistent company const authRes = await provider.query(` DELETE FROM google_auth WHERE selected_company IS NULL OR selected_company NOT IN (SELECT id FROM company) `); console.log(` Cleaned ${authRes.rowsAffected || 0} google_auth user(s) with no valid company.`); // Storage files on disk const removedFiles = await cleanStorageDiskFiles(provider); console.log(` Removed ${removedFiles} orphaned image files from data/storage/`); // If 0 companies remain, clear any leftover folders in data/vfs and data/import if (compCount === 0) { const vfsDir = path.resolve(process.cwd(), 'data/vfs'); if (fs.existsSync(vfsDir)) { for (const f of fs.readdirSync(vfsDir)) { fs.rmSync(path.join(vfsDir, f), { recursive: true, force: true }); } console.log(` Cleaned all entries in data/vfs/`); } const storageVfs = path.resolve(process.cwd(), 'data/storage/vfs'); if (fs.existsSync(storageVfs)) { for (const f of fs.readdirSync(storageVfs)) { fs.rmSync(path.join(storageVfs, f), { recursive: true, force: true }); } console.log(` Cleaned all entries in data/storage/vfs/`); } const importDir = path.resolve(process.cwd(), 'data/import'); if (fs.existsSync(importDir)) { for (const f of fs.readdirSync(importDir)) { fs.rmSync(path.join(importDir, f), { recursive: true, force: true }); } console.log(` Cleaned all entries in data/import/`); } } console.log(chalk.green('\n✔ Cleanup complete.\n')); await closeStore(); } async function listCompanies() { const provider = await getProvider(); const res = await provider.query(` SELECT c.id, c.name, (SELECT COUNT(*) FROM article a WHERE a.company_id = c.id) AS articles, (SELECT COUNT(*) FROM companyuser cu WHERE cu.company_id = c.id) AS users FROM company c ORDER BY c.id `); console.log(chalk.bold('\nAvailable companies:')); console.table(res.rows.map((r) => ({ ID: r.id, Name: r.name, Articles: r.articles, Users: r.users, }))); console.log(`Usage:`); console.log(` npm run wipe-company [--yes]`); console.log(` npm run wipe-company -- --clean-orphans\n`); await closeStore(); } const args = process.argv.slice(2); const autoConfirm = args.includes('--yes') || args.includes('-y'); const shouldCleanOrphans = args.includes('--clean-orphans'); const target = args.find((a) => !a.startsWith('-')); if (shouldCleanOrphans) { await cleanAllOrphans(); } else if (!target) { await listCompanies(); } else { await wipeCompany(target, autoConfirm); }