// src/db/backup_user.ts /* Implement this at some point Solution 1: Use a Custom Backup/Restore Script (Recommended) The most reliable way to handle this is to create dedicated scripts for backing up and restoring a user's data. These scripts can use SQL to intelligently query the database, trace all the foreign key relationships, and generate a complete, ordered SQL file for restoration. How it works: Create a Backup Script (backup_user.js): This Node.js script would connect to the database. It would take a user's email as an argument. It would then systematically query all tables related to that user, starting with the users table and following the foreign keys to shopping_lists, shopping_list_items, watched_items, etc. It would then generate a single user_backup.sql file containing all the INSERT statements needed to recreate that user's data, carefully ordered to respect foreign key constraints (e.g., insert shopping_lists before shopping_list_items). Create a Restore Script (restore_user.js): This script would connect to the database. It would read the user_backup.sql file and execute its contents against the database, restoring the user's data into the newly created schema. Update the Workflow: The manual-db-reset.yml workflow would be simplified to call these scripts. The "Backup" step would run node backup_user.js tsx@gmail.com, and the "Restore" step would run node restore_user.js. */ /** * @file This script creates a data-only backup for a single user. * It traces relationships across tables to export all relevant data as SQL INSERT statements. * * Usage: * tsx src/db/backup_user.ts --email user@example.com --output user_backup.sql */ import { Pool, PoolClient } from 'pg'; import fs from 'fs/promises'; import dotenv from 'dotenv'; import { logger } from '../services/logger.server'; // Load environment variables from the root .env file dotenv.config({ path: '../../.env' }); const pool = new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_DATABASE_PROD, // IMPORTANT: This script targets the production DB password: process.env.DB_PASSWORD, port: parseInt(process.env.DB_PORT || '5432', 10), }); /** * A map defining the tables to back up and the column that links them to the user. * The order is critical to respect foreign key constraints on restore. * Tables with direct user_id foreign keys come first. */ const USER_DATA_TABLES: Record = { 'users': 'user_id', 'profiles': 'user_id', 'pantry_locations': 'user_id', 'shopping_lists': 'user_id', 'recipes': 'user_id', 'menu_plans': 'user_id', 'recipe_collections': 'user_id', 'user_item_aliases': 'user_id', 'user_appliances': 'user_id', 'user_dietary_restrictions': 'user_id', 'favorite_stores': 'user_id', 'favorite_recipes': 'user_id', 'user_watched_items': 'user_id', 'receipts': 'user_id', 'shopping_trips': 'user_id', }; type DBValue = string | number | boolean | null | Date | object; /** * Generates a SQL INSERT statement for a given row of data. * @param table The name of the table. * @param row The data object for the row. * @returns A formatted SQL INSERT statement string. */ function generateInsertStatement(table: string, row: Record): string { const columns = Object.keys(row).map(col => `"${col}"`).join(', '); const values = Object.values(row).map(val => { if (val === null) return 'NULL'; if (val instanceof Date) return `'${val.toISOString()}'`; // Handle Date objects if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`; // Escape single quotes if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // Handle JSONB return val; }).join(', '); return `INSERT INTO public.${table} (${columns}) VALUES (${values});\n`; } async function main() { const args = process.argv.slice(2); const emailIndex = args.indexOf('--email'); const outputIndex = args.indexOf('--output'); if (emailIndex === -1 || outputIndex === -1) { console.error('Usage: tsx src/db/backup_user.ts --email --output '); process.exit(1); } const userEmail = args[emailIndex + 1]; const outputFile = args[outputIndex + 1]; let client: PoolClient | null = null; try { client = await pool.connect(); logger.info(`Connected to database. Backing up user: ${userEmail}`); // 1. Find the user ID const userRes = await client.query('SELECT user_id FROM public.users WHERE email = $1', [userEmail]); if (userRes.rows.length === 0) { logger.warn(`User with email ${userEmail} not found. No backup will be created.`); return; } const userId = userRes.rows[0].user_id; logger.info(`Found user ID: ${userId}`); let backupSql = '-- User Data Backup\n'; backupSql += `-- Generated at: ${new Date().toISOString()}\n`; backupSql += `-- User Email: ${userEmail}\n\n`; // 2. Backup data from tables with a direct user_id link for (const [table, column] of Object.entries(USER_DATA_TABLES)) { const res = await client.query(`SELECT * FROM public.${table} WHERE ${column} = $1`, [userId]); if (res.rows.length > 0) { backupSql += `-- Data for table: ${table}\n`; for (const row of res.rows) { backupSql += generateInsertStatement(table, row); } backupSql += '\n'; } } // 3. Backup data from indirectly related tables (e.g., shopping_list_items) const shoppingListsRes = await client.query('SELECT shopping_list_id FROM public.shopping_lists WHERE user_id = $1', [userId]); const shoppingListIds = shoppingListsRes.rows.map(r => r.shopping_list_id); if (shoppingListIds.length > 0) { const itemsRes = await client.query('SELECT * FROM public.shopping_list_items WHERE shopping_list_id = ANY($1)', [shoppingListIds]); if (itemsRes.rows.length > 0) { backupSql += '-- Data for table: shopping_list_items\n'; for (const row of itemsRes.rows) { backupSql += generateInsertStatement('shopping_list_items', row); } backupSql += '\n'; } } // (Add similar logic for other indirectly related tables like recipe_ingredients, planned_meals, etc.) // 4. Write the final SQL to the output file await fs.writeFile(outputFile, backupSql); logger.info(`✅ Successfully created backup for user ${userEmail} at ${outputFile}`); } catch (error) { logger.error('Failed to create user backup.', { error }); process.exit(1); } finally { client?.release(); await pool.end(); } } main();