2026-04-03 15:17:58 -07:00
#!/usr/bin/env bash
# sync-prod-db.sh
#
# Dumps the production database and restores it to the development database,
# then sanitizes the copy to prevent outbound notifications and reduce PII exposure.
#
# Usage:
2026-04-03 15:38:09 -07:00
# Add PROD_DATABASE_URL to your .env file, then:
# npm run db:sync-prod
# # or pass inline:
# PROD_DATABASE_URL="postgres://..." npm run db:sync-prod
2026-04-03 15:17:58 -07:00
# # or pass as argument:
2026-04-03 15:38:09 -07:00
# bash scripts/sync-prod-db.sh "postgres://..."
2026-04-03 15:17:58 -07:00
#
# Requirements: pg_dump, pg_restore, psql (PostgreSQL client tools)
set -euo pipefail
# ── Colour helpers ────────────────────────────────────────────────────────────
RED = '\033[0;31m'
YELLOW = '\033[1;33m'
GREEN = '\033[0;32m'
BOLD = '\033[1m'
RESET = '\033[0m'
info( ) { echo -e " ${ BOLD } $* ${ RESET } " ; }
warn( ) { echo -e " ${ YELLOW } $* ${ RESET } " ; }
success( ) { echo -e " ${ GREEN } $* ${ RESET } " ; }
error( ) { echo -e " ${ RED } ERROR: $* ${ RESET } " >& 2; exit 1; }
# ── Check required tools (fix #5) ────────────────────────────────────────────
for cmd in pg_dump pg_restore psql; do
command -v " $cmd " & >/dev/null || error " ' $cmd ' not found. Install PostgreSQL client tools (e.g. brew install libpq). "
done
# ── Load .env ─────────────────────────────────────────────────────────────────
SCRIPT_DIR = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) "
ENV_FILE = " $SCRIPT_DIR /../.env "
if [ [ -f " $ENV_FILE " ] ] ; then
2026-04-03 15:38:09 -07:00
# Export DATABASE_URL and PROD_DATABASE_URL from .env if not already set in the environment
2026-04-03 15:17:58 -07:00
DATABASE_URL_FROM_ENV = $( grep -E '^DATABASE_URL=' " $ENV_FILE " | head -1 | cut -d= -f2- | sed 's/^"//' | sed 's/"$//' )
if [ [ -n " $DATABASE_URL_FROM_ENV " && -z " ${ DATABASE_URL :- } " ] ] ; then
export DATABASE_URL = " $DATABASE_URL_FROM_ENV "
fi
2026-04-03 15:38:09 -07:00
PROD_DATABASE_URL_FROM_ENV = $( grep -E '^PROD_DATABASE_URL=' " $ENV_FILE " | head -1 | cut -d= -f2- | sed 's/^"//' | sed 's/"$//' )
if [ [ -n " $PROD_DATABASE_URL_FROM_ENV " && -z " ${ PROD_DATABASE_URL :- } " ] ] ; then
export PROD_DATABASE_URL = " $PROD_DATABASE_URL_FROM_ENV "
fi
2026-04-03 15:17:58 -07:00
fi
# ── Resolve URLs ──────────────────────────────────────────────────────────────
# PROD_DATABASE_URL: env var or first positional argument
PROD_DB = " ${ PROD_DATABASE_URL :- ${ 1 :- } } "
DEV_DB = " ${ DATABASE_URL :- } "
[ [ -z " $PROD_DB " ] ] && error "PROD_DATABASE_URL is not set.\nSet it as an environment variable or pass it as the first argument:\n PROD_DATABASE_URL=\"postgres://...\" npm run db:sync-prod"
[ [ -z " $DEV_DB " ] ] && error "DATABASE_URL is not set. Add it to your .env file."
# Guard against accidentally syncing prod → prod
if [ [ " $PROD_DB " = = " $DEV_DB " ] ] ; then
error "PROD_DATABASE_URL and DATABASE_URL are the same. Aborting to prevent data loss."
fi
# ── Mask password for display (fix #6) ───────────────────────────────────────
# Replaces only the password portion (between : and @) using sed, not bash globs.
mask_url( ) {
echo " $1 " | sed 's|\(://[^:]*\):[^@]*@|\1:***@|'
}
# ── Confirmation prompt ───────────────────────────────────────────────────────
echo ""
warn "⚠️ WARNING: This will OVERWRITE your development database with production data."
echo ""
echo -e " Source (prod): ${ BOLD } $( mask_url " $PROD_DB " ) ${ RESET } "
echo -e " Target (dev): ${ BOLD } $( mask_url " $DEV_DB " ) ${ RESET } "
echo ""
warn "After restore, the script will:"
echo " • NULL out all Discord webhook URLs (prevents notification leaks)"
echo " • Anonymize email/name/avatar for non-admin users (reduces PII exposure)"
echo " • Admin accounts are left untouched so you can still access admin pages"
echo ""
read -r -p "Type 'yes' to continue: " CONFIRM
if [ [ " $CONFIRM " != "yes" ] ] ; then
echo "Aborted."
exit 0
fi
# ── Dump ──────────────────────────────────────────────────────────────────────
DUMP_FILE = $( mktemp /tmp/brackt_prod_dump_XXXXXX.dump)
# Ensure the temp file is cleaned up on exit
trap 'rm -f "$DUMP_FILE"' EXIT
info ""
info "1/3 Dumping production database…"
pg_dump \
--no-owner \
--no-acl \
--format= custom \
" $PROD_DB " \
--file= " $DUMP_FILE "
success " Dump complete: $( du -h " $DUMP_FILE " | cut -f1) "
# ── Restore (fix #1, #2, #3) ─────────────────────────────────────────────────
# Uses --clean --if-exists to drop and recreate all objects within the existing
# database, without needing DROP DATABASE / CREATE DATABASE permissions.
# This works on managed cloud databases (Railway, Render, Neon, Supabase, etc.).
#
# pg_restore exits with code 1 for non-fatal warnings (e.g. "role X does not
# exist" with --no-owner), so we capture the exit code and only abort on
# unexpected failures, not routine restore warnings.
info ""
info "2/3 Restoring production dump to development database…"
RESTORE_EXIT = 0
pg_restore \
--no-owner \
--no-acl \
--clean \
--if-exists \
--dbname= " $DEV_DB " \
" $DUMP_FILE " || RESTORE_EXIT = $?
if [ [ $RESTORE_EXIT -eq 0 ] ] ; then
success " Restore complete."
elif [ [ $RESTORE_EXIT -eq 1 ] ] ; then
warn " Restore completed with warnings (non-fatal). Continuing…"
else
error " pg_restore failed with exit code $RESTORE_EXIT . Aborting. "
fi
# ── Sanitize (fix #4) ────────────────────────────────────────────────────────
info ""
info "3/3 Sanitizing development database…"
SANITIZE_RESULT = $( psql " $DEV_DB " --tuples-only --no-align << 'SQL'
BEGIN;
-- 1. Count leagues with webhooks before clearing ( accurate count)
SELECT 'discord_webhooks_nulled:' || COUNT( *) FROM leagues WHERE discord_webhook_url IS NOT NULL;
UPDATE leagues SET discord_webhook_url = NULL;
-- 2. Anonymize PII for non-admin users only
-- ( admin accounts are untouched so admin pages remain accessible)
UPDATE users SET
email = 'user_' || id || '@example.com' ,
display_name = 'Test User' ,
image_url = NULL
WHERE is_admin = FALSE;
SELECT 'users_anonymized:' || COUNT( *) FROM users WHERE is_admin = FALSE;
SELECT 'admin_users_kept:' || COUNT( *) FROM users WHERE is_admin = TRUE;
COMMIT;
SQL
)
LEAGUES_COUNT = $( echo " $SANITIZE_RESULT " | grep 'discord_webhooks_nulled:' | cut -d: -f2 | tr -d ' ' )
ANON_COUNT = $( echo " $SANITIZE_RESULT " | grep 'users_anonymized:' | cut -d: -f2 | tr -d ' ' )
ADMIN_COUNT = $( echo " $SANITIZE_RESULT " | grep 'admin_users_kept:' | cut -d: -f2 | tr -d ' ' )
echo ""
success "✓ Sync complete. Sanitization summary:"
echo " • Discord webhook URLs cleared: ${ LEAGUES_COUNT :- ? } leagues "
echo " • Non-admin users anonymized: ${ ANON_COUNT :- ? } users "
echo " • Admin accounts preserved: ${ ADMIN_COUNT :- ? } users "
echo ""
warn "Reminder: ensure your .env does NOT contain:"
echo " • RESEND_API_KEY (email sending)"
echo " • SENTRY_AUTH_TOKEN (error tracking)"
echo " • Production Clerk keys (use dev Clerk instance)"
echo ""