Fix analytics case() bug, add privacy mode, add prod DB sync script
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s

Fix SQLAlchemy case() import in monthly-trend endpoint. Add
data-sensitive attributes to Analytics charts and tables for privacy
blur. Add scripts/sync-db.sh for one-click prod-to-local PostgreSQL
sync. Remove SQLite artifacts from gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-03 20:10:58 -06:00
parent 78e20f30cb
commit 792cef5006
4 changed files with 114 additions and 13 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules/
dist/ dist/
__pycache__/ __pycache__/
*.pyc *.pyc
*.db
*.db.bak
.env .env
.env.* .env.*
!.env.example !.env.example

View File

@@ -3,12 +3,13 @@ from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import case
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Category, Transaction from app.models.models import Category, Transaction
from app.api.v1.endpoints.transactions import get_cycle_range from app.services.budget_projection import get_cycle_range
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -104,7 +105,7 @@ def monthly_trend(
func.count(), func.count(),
func.coalesce( func.coalesce(
func.sum( func.sum(
func.case( case(
(Transaction.currency == "CRC", Transaction.amount), (Transaction.currency == "CRC", Transaction.amount),
else_=0, else_=0,
) )
@@ -113,7 +114,7 @@ def monthly_trend(
), ),
func.coalesce( func.coalesce(
func.sum( func.sum(
func.case( case(
(Transaction.currency == "USD", Transaction.amount), (Transaction.currency == "USD", Transaction.amount),
else_=0, else_=0,
) )

View File

@@ -46,9 +46,8 @@ interface DailySpending {
} }
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', '#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)', '#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
]; ];
function formatCRC(value: number) { function formatCRC(value: number) {
@@ -132,7 +131,7 @@ export default function Analytics() {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<ChartContainer config={pieChartConfig} className="h-[260px] w-full"> <ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart> <PieChart>
<Pie <Pie
data={byCategory} data={byCategory}
@@ -168,7 +167,7 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }} style={{ background: COLORS[i % COLORS.length] }}
/> />
<span className="text-muted-foreground truncate">{cat.category_name}</span> <span className="text-muted-foreground truncate">{cat.category_name}</span>
<span className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span> <span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
</div> </div>
))} ))}
</div> </div>
@@ -190,7 +189,7 @@ export default function Analytics() {
No data No data
</div> </div>
) : ( ) : (
<ChartContainer config={trendChartConfig} className="h-[300px] w-full"> <ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}> <BarChart data={trend}>
<XAxis <XAxis
dataKey="label" dataKey="label"
@@ -229,7 +228,7 @@ export default function Analytics() {
No data for this period No data for this period
</div> </div>
) : ( ) : (
<ChartContainer config={dailyChartConfig} className="h-[240px] w-full"> <ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}> <LineChart data={daily}>
<XAxis <XAxis
dataKey="date" dataKey="date"
@@ -287,11 +286,11 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }} style={{ background: COLORS[i % COLORS.length] }}
/> />
<span className="text-sm flex-1">{cat.category_name}</span> <span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-muted-foreground">{cat.count} txns</span> <span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right"> <span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)} {formatCRC(cat.total)}
</span> </span>
<div className="w-24 bg-muted rounded-full h-1.5"> <div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
<div <div
className="h-1.5 rounded-full" className="h-1.5 rounded-full"
style={{ style={{

99
scripts/sync-db.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────
PROD_SSH_ALIAS="production"
PROD_CONTAINER="wealthysmart-db-prod"
PROD_DB="wealthysmart"
PROD_USER="wealthy_user"
LOCAL_CONTAINER="wealthysmart-db-dev"
LOCAL_DB="wealthysmart"
LOCAL_USER="wealthy_user"
LOCAL_PASS="wealthy_pass"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DUMP_FILE="$(mktemp -t wealthysmart-dump-XXXXXX)"
# ── Cleanup on exit ─────────────────────────────────────────────
cleanup() { rm -f "$DUMP_FILE"; }
trap cleanup EXIT
# ── Confirmation ─────────────────────────────────────────────────
echo "=== WealthySmart Database Sync ==="
echo ""
echo "This will DESTROY your local dev database and replace it"
echo "with a copy of production data."
echo ""
read -r -p "Continue? [y/N] " confirm
if [[ "$confirm" != [yY] ]]; then
echo "Aborted."
exit 0
fi
# ── 1. Dump production ──────────────────────────────────────────
echo ""
echo "[1/5] Dumping production database..."
ssh "$PROD_SSH_ALIAS" \
"docker exec $PROD_CONTAINER pg_dump \
--format=custom \
--no-owner \
--no-acl \
-U $PROD_USER \
$PROD_DB" > "$DUMP_FILE"
if [[ ! -s "$DUMP_FILE" ]]; then
echo "ERROR: Dump file is empty. SSH or pg_dump may have failed."
exit 1
fi
DUMP_SIZE=$(du -h "$DUMP_FILE" | cut -f1)
echo " Done. Dump size: $DUMP_SIZE"
# ── 2. Ensure local DB container is running ─────────────────────
echo "[2/5] Ensuring local dev database is running..."
cd "$PROJECT_ROOT"
if ! docker inspect --format='{{.State.Running}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "true"; then
echo " Starting db service..."
docker compose up -d db
fi
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "healthy"; then
break
fi
if [[ $i -eq 30 ]]; then
echo "ERROR: Local DB container did not become healthy within 30s."
exit 1
fi
sleep 1
done
echo " Local DB is running and healthy."
# ── 3. Drop and recreate local database ─────────────────────────
echo "[3/5] Dropping and recreating local dev database..."
docker exec "$LOCAL_CONTAINER" bash -c \
"PGPASSWORD='$LOCAL_PASS' dropdb -U $LOCAL_USER --if-exists $LOCAL_DB && \
PGPASSWORD='$LOCAL_PASS' createdb -U $LOCAL_USER $LOCAL_DB"
echo " Done."
# ── 4. Restore ──────────────────────────────────────────────────
echo "[4/5] Restoring dump into local dev database..."
docker exec -i "$LOCAL_CONTAINER" pg_restore \
--no-owner \
--no-acl \
--dbname="$LOCAL_DB" \
-U "$LOCAL_USER" < "$DUMP_FILE"
# ── 5. Run pending migrations ───────────────────────────────────
echo "[5/5] Running pending migrations..."
docker exec "$LOCAL_CONTAINER" psql -U "$LOCAL_USER" -d "$LOCAL_DB" -c \
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false;" \
2>/dev/null || true
echo " Done."
echo ""
echo "=== Sync complete! ==="
echo "Local dev database now mirrors production."