'use strict'; import type { Db } from '../types/db'; const { ensureUserDefaultCategories } = require('../db/database.cts'); // Independent user-owned tables (each has a user_id) wiped wholesale. const USER_TABLES = [ 'bill_merchant_rules', 'match_suggestion_rejections', 'autopay_suggestion_dismissals', 'declined_subscription_hints', 'subscription_recommendation_feedback', 'user_catalog_descriptors', 'spending_category_rules', 'spending_budgets', 'bill_templates', 'snowball_plans', 'monthly_starting_amounts', 'monthly_income', 'calendar_tokens', 'import_sessions', 'import_history', ]; /** * Permanently erase a user's financial + imported data, resetting them to a clean * slate — WITHOUT touching the account, sessions, 2FA/WebAuthn, login history, or * preferences. Runs in one transaction (child → parent order so it holds with * foreign_keys ON), then re-seeds default categories so the app stays usable and * writes an audit row to import_history. */ function eraseUserData(db: Db, userId: number): { erased: number; counts: Record } { const counts: Record = {}; const del = (label: string, sql: string) => { counts[label] = db.prepare(sql).run(userId).changes; }; const run = db.transaction(() => { // Bill-children (no user_id of their own) — remove before bills. del( 'payments', 'DELETE FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)', ); del( 'monthly_bill_state', 'DELETE FROM monthly_bill_state WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)', ); del( 'bill_history_ranges', 'DELETE FROM bill_history_ranges WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)', ); // Bank-data chain: transactions → accounts → sources. del('transactions', 'DELETE FROM transactions WHERE user_id = ?'); del('financial_accounts', 'DELETE FROM financial_accounts WHERE user_id = ?'); del('data_sources', 'DELETE FROM data_sources WHERE user_id = ?'); for (const t of USER_TABLES) del(t, `DELETE FROM ${t} WHERE user_id = ?`); // bills → categories → category_groups (bills.category_id, categories.group_id FKs). del('bills', 'DELETE FROM bills WHERE user_id = ?'); del('categories', 'DELETE FROM categories WHERE user_id = ?'); del('category_groups', 'DELETE FROM category_groups WHERE user_id = ?'); }); run(); // Re-seed default categories so the app is immediately usable again. ensureUserDefaultCategories(userId); const erased = Object.values(counts).reduce((sum, n) => sum + n, 0); db.prepare( ` INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) VALUES (?, ?, 'erase-my-data', 'erase-data', ?, 0, 0, 0, 0, 0, ?, ?) `, ).run( userId, new Date().toISOString(), erased, JSON.stringify({ action: 'erase-my-data' }), JSON.stringify(counts), ); return { erased, counts }; } // User-scoped tables the demo seeder marks with is_seeded = 1 (not bill-children, // which cascade with their seeded bill). Keep in sync with scripts/seedDemoData.cts // and the v1.07 migration. const DEMO_MARKER_TABLES = [ 'transactions', 'financial_accounts', 'data_sources', 'category_groups', 'bill_templates', 'snowball_plans', 'spending_category_rules', 'spending_budgets', 'monthly_income', 'monthly_starting_amounts', 'calendar_tokens', 'declined_subscription_hints', ]; /** * Surgically remove ONLY the demo data seeded by scripts/seedDemoData.cts for one * user — never their real data. Runs in one transaction, child → parent so it holds * with foreign_keys ON. Marker tables go by is_seeded; bill-children cascade with the * seeded bill. A seeded category is removed only if no surviving (non-seeded) bill * still references it, so a user's own bill that reused a demo category keeps it. * Mirrors eraseUserData(); audits to import_history. */ function clearSeededDemoData( db: Db, userId: number, ): { removed: number; counts: Record } { const counts: Record = {}; const del = (label: string, sql: string) => { counts[label] = db.prepare(sql).run(userId).changes; }; const run = db.transaction(() => { // Bank chain first (transactions cascade their match rejections), then the rest // of the independent marker tables. for (const t of DEMO_MARKER_TABLES) del(t, `DELETE FROM ${t} WHERE user_id = ? AND is_seeded = 1`); // Bill-children counted explicitly (they also cascade with the bill below). del( 'payments', 'DELETE FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)', ); del('bills', 'DELETE FROM bills WHERE user_id = ? AND is_seeded = 1'); // Seeded categories only if no surviving bill still references them. counts.categories = db .prepare( `DELETE FROM categories WHERE user_id = ? AND is_seeded = 1 AND id NOT IN (SELECT category_id FROM bills WHERE user_id = ? AND category_id IS NOT NULL)`, ) .run(userId, userId).changes; // Reset the demo snowball extra-payment. db.prepare('UPDATE users SET snowball_extra_payment = 0 WHERE id = ?').run(userId); }); run(); const removed = Object.values(counts).reduce((sum, n) => sum + n, 0); db.prepare( ` INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) VALUES (?, ?, 'clear-demo-data', 'clear-demo', ?, 0, 0, 0, 0, 0, ?, ?) `, ).run( userId, new Date().toISOString(), removed, JSON.stringify({ action: 'clear-demo-data' }), JSON.stringify(counts), ); return { removed, counts }; } module.exports = { eraseUserData, clearSeededDemoData };