diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4779a05e..7c69f236 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -94,6 +94,12 @@ jobs: GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} run: npm run test:coverage + # Fail-fast check to ensure secrets are configured in Gitea for testing. + if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ] || [ -z "$GEMINI_API_KEY" ]; then + echo "ERROR: One or more test secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE, VITE_GOOGLE_GENAI_API_KEY) are not set." + exit 1 + fi + continue-on-error: true # Allows the workflow to proceed even if tests fail. - name: Archive Code Coverage Report @@ -112,6 +118,12 @@ jobs: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_DATABASE: ${{ secrets.DB_DATABASE_PROD }} # Assumes a secret for the production DB name. run: | + # Fail-fast check to ensure secrets are configured in Gitea. + if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ]; then + echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set in Gitea repository settings." + exit 1 + fi + echo "--- Checking for schema changes ---" # Calculate the hash of the current schema file in the repository. # We normalize line endings to ensure the hash is consistent across different OS environments. @@ -144,6 +156,12 @@ jobs: # This maps the Gitea secret to the environment variable the application expects. # We also generate and inject the application version, commit URL, and commit message. run: | + # Fail-fast check for the build-time secret. + if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then + echo "ERROR: The VITE_GOOGLE_GENAI_API_KEY secret is not set." + exit 1 + fi + GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL COMMIT_MESSAGE=$(git log -1 --pretty=%s) VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \ @@ -178,6 +196,12 @@ jobs: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_DATABASE: ${{ secrets.DB_DATABASE_PROD }} run: | + # Fail-fast check to ensure secrets are configured in Gitea. + if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ]; then + echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set in Gitea repository settings." + exit 1 + fi + echo "Installing production dependencies and restarting server..." cd /var/www/flyer-crawler.projectium.com npm install --omit=dev # Install only production dependencies diff --git a/.gitea/workflows/manual-db-reset.yml b/.gitea/workflows/manual-db-reset.yml index cdb5b766..c9ef8f25 100644 --- a/.gitea/workflows/manual-db-reset.yml +++ b/.gitea/workflows/manual-db-reset.yml @@ -32,6 +32,15 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 + - name: Validate Secrets + run: | + # Fail-fast check to ensure secrets are configured in Gitea. + if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ]; then + echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set in Gitea repository settings." + exit 1 + fi + echo "✅ All required database secrets are present." + - name: Verify Confirmation Phrase run: | if [ "${{ gitea.event.inputs.confirmation }}" != "reset-production-db" ]; then diff --git a/src/db/backup_user.ts b/src/db/backup_user.ts index e69de29b..f786f494 100644 --- a/src/db/backup_user.ts +++ b/src/db/backup_user.ts @@ -0,0 +1,165 @@ +/* + +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', +}; + +/** + * 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 (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(); +