Aria / database.py
teganmosi
Support non-crashing lazy database pool initialization on startup
2bbbe4f
Raw
History Blame Contribute Delete
59.9 kB
import os
import json
import logging
import uuid
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
import psycopg2
import psycopg2.extras
from psycopg2.pool import ThreadedConnectionPool
from supabase import create_client, Client
from config import settings
logger = logging.getLogger(__name__)
# Monkeypatch to fix a bug in supabase-py 2.7.0 client.py where realtime client is hardcoded to None,
# causing AttributeErrors during sign_in_with_password because the library tries to do self.realtime.set_auth().
try:
from supabase._sync.client import SyncClient
def safe_listen_to_auth_events(self, event, session):
access_token = self.supabase_key
if event in ["SIGNED_IN", "TOKEN_REFRESHED", "SIGNED_OUT"]:
self._postgrest = None
self._storage = None
self._functions = None
access_token = session.access_token if session else self.supabase_key
self.options.headers["Authorization"] = self._create_auth_header(access_token)
# Only call set_auth if realtime is initialized and not None
if self.realtime is not None:
self.realtime.set_auth(access_token)
SyncClient._listen_to_auth_events = safe_listen_to_auth_events
logger.info("Successfully patched Supabase SyncClient._listen_to_auth_events for safety")
except Exception as e:
logger.error(f"Failed to patch Supabase SyncClient: {e}")
class Database:
_instance: Optional["Database"] = None
_pool: Optional[ThreadedConnectionPool] = None
_tables_ensured: bool = False
_ensuring_tables: bool = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
_COLUMN_ALLOWLISTS: Dict[str, frozenset] = {
"profiles": frozenset({"id", "email", "full_name", "avatar_url", "preferred_bible_version",
"notification_preferences", "spiritual_journey_notes",
"aria_custom_prompt", "aria_personal_context", "aria_voice",
"created_at", "updated_at"}),
"bible_study_sessions": frozenset({"id", "user_id", "book", "chapter", "verses",
"selected_text", "created_at", "updated_at"}),
"bible_study_messages": frozenset({"id", "session_id", "role", "content", "created_at"}),
"emotional_support_sessions": frozenset({"id", "user_id", "mood", "provided_scriptures",
"created_at", "updated_at"}),
"emotional_support_messages": frozenset({"id", "session_id", "role", "content", "created_at"}),
"devotion_settings": frozenset({"id", "user_id", "topics", "preferred_time", "frequency",
"created_at", "updated_at"}),
"devotions": frozenset({"id", "user_id", "title", "day_plan_summary", "scripture_reading",
"reflection", "prayer", "status", "scheduled_date",
"created_at", "updated_at"}),
"devotion_messages": frozenset({"id", "devotion_id", "role", "content", "created_at"}),
"notes": frozenset({"id", "user_id", "title", "content", "source_type", "source_reference",
"tags", "is_locked", "password_hash", "created_at", "updated_at"}),
"prayers": frozenset({"id", "user_id", "title", "content", "is_answered", "created_at",
"updated_at"}),
"ai_chat_sessions": frozenset({"id", "user_id", "title", "created_at", "updated_at"}),
"ai_chat_messages": frozenset({"id", "session_id", "role", "content", "created_at"}),
}
def _validate_columns(self, table: str, keys) -> None:
allowed = self._COLUMN_ALLOWLISTS.get(table, frozenset())
bad = frozenset(keys) - allowed
if bad:
raise ValueError(f"Disallowed column(s) for table '{table}': {bad}")
_client: Optional[Client] = None
def __init__(self):
if hasattr(self, '_initialized'):
return
self._initialized = True
# Initialize Supabase client if credentials are provided
if settings.supabase_url and settings.supabase_key:
try:
self.__class__._client = create_client(settings.supabase_url, settings.supabase_key)
logger.info("Supabase client initialized with anon key")
except Exception as e:
logger.error(f"⚠️ Failed to initialize Supabase client (possibly invalid key): {e}")
if not self.__class__._pool:
try:
self.__class__._pool = ThreadedConnectionPool(
minconn=1,
maxconn=10,
dsn=settings.database_url,
)
# Ensure tables exist in the database (whether local or remote Supabase)
self._ensure_tables()
except Exception as e:
logger.error(f"⚠️ Failed to initialize database connection pool on startup: {e}")
logger.warning("Database connection will be retried lazily during request handling.")
@property
def client(self) -> Client:
if not self.__class__._client:
raise RuntimeError("Supabase client is not initialized. Please check your SUPABASE_URL and SUPABASE_KEY configuration.")
return self.__class__._client
@contextmanager
def get_connection(self):
if not self.__class__._pool:
logger.info("Attempting lazy database pool initialization...")
try:
self.__class__._pool = ThreadedConnectionPool(
minconn=1,
maxconn=10,
dsn=settings.database_url,
)
except Exception as e:
logger.error(f"Lazy pool initialization failed: {e}")
raise RuntimeError(f"Database connection pool not initialized: {e}")
if not self.__class__._tables_ensured:
self._ensure_tables()
conn = self._pool.getconn()
close_conn = False
try:
yield conn
conn.commit()
except (psycopg2.OperationalError, psycopg2.InterfaceError) as e:
logger.warning(f"Database connection error: {e}. Discarding connection from pool.")
close_conn = True
raise
except Exception:
try:
if conn and not conn.closed:
conn.rollback()
except Exception as rollback_err:
logger.warning(f"Failed to rollback connection: {rollback_err}")
close_conn = True
raise
finally:
try:
self._pool.putconn(conn, close=close_conn)
except Exception as put_err:
logger.error(f"Error returning connection to pool: {put_err}")
def _ensure_tables(self):
if self.__class__._tables_ensured or self.__class__._ensuring_tables:
return
self.__class__._ensuring_tables = True
try:
# Dynamically check existing id column types to prevent foreign key datatype mismatches (UUID vs TEXT)
id_type = None
try:
with self.get_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'id'
UNION ALL
SELECT data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'id'
""")
rows = cur.fetchall()
for r in rows:
if r and r[0]:
val = r[0].upper()
id_type = "UUID" if "UUID" in val else "TEXT"
logger.info(f"Detected users/profiles id type from database: {id_type}")
break
except Exception as e:
logger.warning(f"Failed to auto-detect users/profiles id type: {e}")
if not id_type:
is_supabase_db = "supabase.co" in settings.database_url or "supabase.com" in settings.database_url
id_type = "UUID" if is_supabase_db else "TEXT"
logger.info(f"Fallback id type determined: {id_type}")
with self.get_connection() as conn:
cur = conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS users (
id {id_type} PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS profiles (
id {id_type} PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE,
full_name TEXT,
avatar_url TEXT,
preferred_bible_version TEXT DEFAULT 'NIV',
notification_preferences TEXT DEFAULT '{{\"email\": true, \"push\": true}}',
spiritual_journey_notes TEXT,
aria_custom_prompt TEXT,
aria_personal_context TEXT,
aria_voice TEXT DEFAULT 'verse',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS prayers (
id TEXT PRIMARY KEY,
user_id {id_type} NOT NULL,
title TEXT,
content TEXT NOT NULL,
isanswered BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS bible_study_sessions (
id {id_type} PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
book TEXT NOT NULL,
chapter INTEGER NOT NULL,
verses TEXT NOT NULL,
selected_text TEXT NOT NULL,
is_realtime BOOLEAN DEFAULT FALSE,
ai_explanation TEXT,
ai_context TEXT,
conversation_summary TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS bible_study_messages (
id {id_type} PRIMARY KEY,
session_id {id_type} NOT NULL REFERENCES bible_study_sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS emotional_support_sessions (
id {id_type} PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
mood TEXT NOT NULL,
situation_description TEXT,
is_realtime BOOLEAN DEFAULT FALSE,
ai_response TEXT,
provided_scriptures TEXT,
prayer_suggestion TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS emotional_support_messages (
id {id_type} PRIMARY KEY,
session_id {id_type} NOT NULL REFERENCES emotional_support_sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS devotion_settings (
user_id {id_type} PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
preferred_time TEXT NOT NULL,
timezone TEXT NOT NULL,
duration_minutes INTEGER DEFAULT 15,
topics TEXT DEFAULT '[]',
auto_prayer BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS devotions (
id {id_type} PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
scheduled_for TIMESTAMP WITH TIME ZONE NOT NULL,
day_plan_summary TEXT,
scripture_reading TEXT,
reflection_prompt TEXT,
user_reflection TEXT,
status TEXT DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'skipped')),
completed_at TIMESTAMP WITH TIME ZONE,
ai_prayer TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS bible_verses (
id SERIAL PRIMARY KEY,
book TEXT NOT NULL,
chapter INTEGER NOT NULL,
verse INTEGER NOT NULL,
text TEXT NOT NULL,
version TEXT DEFAULT 'NIV',
UNIQUE (book, chapter, verse, version)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS scripture_references (
id SERIAL PRIMARY KEY,
verse_id INTEGER NOT NULL REFERENCES bible_verses(id) ON DELETE CASCADE,
category TEXT NOT NULL,
tags TEXT NOT NULL,
context_description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS user_favorites (
id TEXT PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('verse', 'prayer', 'devotion')),
item_id TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (user_id, item_type, item_id)
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS journal_entries (
id TEXT PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT,
content TEXT NOT NULL,
mood TEXT,
related_scriptures TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS cached_verses (
id SERIAL PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
verse_text TEXT NOT NULL,
verse_reference TEXT NOT NULL,
aria_insight TEXT,
daily_manna TEXT,
cached_date TEXT NOT NULL
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT,
content TEXT NOT NULL,
source_type TEXT DEFAULT 'general',
source_reference TEXT,
tags TEXT DEFAULT '[]',
is_locked BOOLEAN DEFAULT FALSE,
password_hash TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS devotion_messages (
id {id_type} PRIMARY KEY,
devotion_id {id_type} NOT NULL REFERENCES devotions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
id {id_type} PRIMARY KEY,
user_id {id_type} NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS ai_chat_messages (
id {id_type} PRIMARY KEY,
session_id {id_type} NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS revoked_tokens (
jti TEXT PRIMARY KEY,
revoked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id {id_type} NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
# Indexes
cur.execute("CREATE INDEX IF NOT EXISTS idx_bible_study_sessions_user_id ON bible_study_sessions (user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_bible_study_messages_session_id ON bible_study_messages (session_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_emotional_support_sessions_user_id ON emotional_support_sessions (user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_devotions_user_id ON devotions (user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_devotion_messages_devotion_id ON devotion_messages (devotion_id)")
self.__class__._tables_ensured = True
except Exception:
logger.exception("Error ensuring tables")
finally:
self.__class__._ensuring_tables = False
def _cursor(self, conn):
return conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
def _parse_json_fields(self, d: Dict) -> None:
for field in ('notification_preferences', 'provided_scriptures', 'scripture_reading',
'related_scriptures', 'topics', 'tags'):
if field in d and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except json.JSONDecodeError:
pass
def _parse_verses_field(self, d: Dict) -> None:
val = d.get('verses')
if isinstance(val, str) and val.strip():
try:
d['verses'] = [int(v.strip()) for v in val.split(',') if v.strip()]
except (ValueError, TypeError):
d['verses'] = []
elif not isinstance(val, list):
if val is not None:
d['verses'] = []
def to_dict(self, row) -> Optional[Dict]:
if row is None:
return None
d = dict(row)
self._parse_json_fields(d)
self._parse_verses_field(d)
return d
# ==================== Auth & Token Operations ====================
def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
return self.to_dict(cur.fetchone())
def create_user(self, user_id: str, email: str, hashed_password: str) -> bool:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"INSERT INTO users (id, email, hashed_password) VALUES (%s, %s, %s) ON CONFLICT (id) DO NOTHING",
(user_id, email, hashed_password),
)
return True
except Exception:
logger.exception("Error creating user")
return False
def revoke_token(self, jti: str, expires_at: str) -> None:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"INSERT INTO revoked_tokens (jti, expires_at) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(jti, expires_at),
)
except Exception:
logger.exception("Error revoking token")
def is_token_revoked(self, jti: str) -> bool:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT 1 FROM revoked_tokens WHERE jti = %s", (jti,))
return cur.fetchone() is not None
except Exception:
logger.exception("Error checking token revocation")
return False
def store_refresh_token(self, token: str, user_id: str, expires_at: str) -> None:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
(token, user_id, expires_at),
)
except Exception:
logger.exception("Error storing refresh token")
def get_refresh_token(self, token: str) -> Optional[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"SELECT * FROM refresh_tokens WHERE token = %s AND revoked = FALSE AND expires_at > NOW()",
(token,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception:
logger.exception("Error getting refresh token")
return None
def revoke_refresh_token(self, token: str) -> None:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("UPDATE refresh_tokens SET revoked = TRUE WHERE token = %s", (token,))
except Exception:
logger.exception("Error revoking refresh token")
# ==================== Profile Operations ====================
def get_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id = %s", (user_id,))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error getting profile")
return None
def create_profile(self, profile_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = profile_data.copy()
if 'notification_preferences' in data and not isinstance(data['notification_preferences'], str):
data['notification_preferences'] = json.dumps(data['notification_preferences'])
self._validate_columns("profiles", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO profiles ({cols}) VALUES ({placeholders})", list(data.values()))
return self.get_profile(data['id'])
except Exception:
logger.exception("Error creating profile")
return None
def update_profile(self, user_id: str, profile_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = profile_data.copy()
if 'notification_preferences' in data and not isinstance(data['notification_preferences'], str):
data['notification_preferences'] = json.dumps(data['notification_preferences'])
self._validate_columns("profiles", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE profiles SET {set_clause}, updated_at = NOW() WHERE id = %s",
list(data.values()) + [user_id],
)
return self.get_profile(user_id)
except Exception:
logger.exception("Error updating profile")
return None
# ==================== Bible Study Operations ====================
def create_bible_study_session(self, session_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = session_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
if isinstance(data.get('verses'), list):
data['verses'] = ','.join(map(str, data['verses']))
self._validate_columns("bible_study_sessions", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO bible_study_sessions ({cols}) VALUES ({placeholders})", list(data.values()))
return self.get_bible_study_session(data['id'])
except Exception:
logger.exception("Error creating bible study session")
return None
def get_bible_study_sessions(self, user_id: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM bible_study_sessions WHERE user_id = %s ORDER BY created_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
def get_bible_study_session(self, session_id: str) -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM bible_study_sessions WHERE id = %s", (session_id,))
return self.to_dict(cur.fetchone())
def update_bible_study_session(self, session_id: str, session_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = session_data.copy()
self._validate_columns("bible_study_sessions", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE bible_study_sessions SET {set_clause}, updated_at = NOW() WHERE id = %s",
list(data.values()) + [session_id],
)
return self.get_bible_study_session(session_id)
except Exception:
logger.exception("Error updating session")
return None
def create_bible_study_message(self, message_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = message_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
self._validate_columns("bible_study_messages", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO bible_study_messages ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM bible_study_messages WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating message")
return None
def get_bible_study_messages(self, session_id: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM bible_study_messages WHERE session_id = %s ORDER BY created_at ASC", (session_id,))
return [self.to_dict(r) for r in cur.fetchall()]
# ==================== Emotional Support Operations ====================
def create_emotional_support_session(self, session_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = session_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
if 'provided_scriptures' in data and not isinstance(data['provided_scriptures'], str):
data['provided_scriptures'] = json.dumps(data['provided_scriptures'])
self._validate_columns("emotional_support_sessions", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO emotional_support_sessions ({cols}) VALUES ({placeholders})", list(data.values()))
return self.get_emotional_support_session(data['id'])
except Exception:
logger.exception("Error creating emotional session")
return None
def get_emotional_support_sessions(self, user_id: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM emotional_support_sessions WHERE user_id = %s ORDER BY created_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
def get_emotional_support_session(self, session_id: str) -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM emotional_support_sessions WHERE id = %s", (session_id,))
return self.to_dict(cur.fetchone())
def update_emotional_support_session(self, session_id: str, session_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = session_data.copy()
if 'provided_scriptures' in data and not isinstance(data['provided_scriptures'], str):
data['provided_scriptures'] = json.dumps(data['provided_scriptures'])
self._validate_columns("emotional_support_sessions", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE emotional_support_sessions SET {set_clause}, updated_at = NOW() WHERE id = %s",
list(data.values()) + [session_id],
)
return self.get_emotional_support_session(session_id)
except Exception:
logger.exception("Error updating emotional session")
return None
def create_emotional_support_message(self, message_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = message_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
self._validate_columns("emotional_support_messages", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO emotional_support_messages ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM emotional_support_messages WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating support message")
return None
def get_emotional_support_messages(self, session_id: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM emotional_support_messages WHERE session_id = %s ORDER BY created_at ASC", (session_id,))
return [self.to_dict(r) for r in cur.fetchall()]
# ==================== Devotion Operations ====================
def get_devotion_settings(self, user_id: str) -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM devotion_settings WHERE user_id = %s", (user_id,))
return self.to_dict(cur.fetchone())
def create_devotion_settings(self, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = settings_data.copy()
if 'topics' in data and not isinstance(data['topics'], str):
data['topics'] = json.dumps(data['topics'])
self._validate_columns("devotion_settings", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO devotion_settings ({cols}) VALUES ({placeholders})", list(data.values()))
return self.get_devotion_settings(data['user_id'])
except Exception:
logger.exception("Error creating devotion settings")
return None
def update_devotion_settings(self, user_id: str, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = settings_data.copy()
if 'topics' in data and not isinstance(data['topics'], str):
data['topics'] = json.dumps(data['topics'])
self._validate_columns("devotion_settings", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE devotion_settings SET {set_clause}, updated_at = NOW() WHERE user_id = %s",
list(data.values()) + [user_id],
)
return self.get_devotion_settings(user_id)
except Exception:
logger.exception("Error updating devotion settings")
return None
def create_devotion(self, devotion_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = devotion_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
if 'scripture_reading' in data and not isinstance(data['scripture_reading'], str):
data['scripture_reading'] = json.dumps(data['scripture_reading'])
self._validate_columns("devotions", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO devotions ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM devotions WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating devotion")
return None
def get_devotions(self, user_id: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM devotions WHERE user_id = %s ORDER BY created_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
def update_devotion(self, devotion_id: str, devotion_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = devotion_data.copy()
if 'scripture_reading' in data and not isinstance(data['scripture_reading'], str):
data['scripture_reading'] = json.dumps(data['scripture_reading'])
self._validate_columns("devotions", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE devotions SET {set_clause} WHERE id = %s",
list(data.values()) + [devotion_id],
)
cur.execute("SELECT * FROM devotions WHERE id = %s", (devotion_id,))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error updating devotion")
return None
def create_devotion_message(self, message_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = message_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
self._validate_columns("devotion_messages", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO devotion_messages ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM devotion_messages WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating devotion message")
return None
def get_devotion_messages(self, devotion_id: str) -> List[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM devotion_messages WHERE devotion_id = %s ORDER BY created_at ASC", (devotion_id,))
return [self.to_dict(r) for r in cur.fetchall()]
except Exception:
logger.exception("Error getting devotion messages")
return []
# ==================== Bible Operations ====================
async def fetch_bible_chapter_from_api(self, book: str, chapter: int, version: str = "KJV") -> List[Dict[str, Any]]:
# Always check local DB first — after seed_kjv.py runs, KJV is fully seeded
# and will never reach the external call below.
local_verses = self.get_bible_chapter_local(book, chapter, version)
if local_verses:
logger.info(f"Loaded {book} {chapter} ({version}) from local DB")
return local_verses
# Fallback for translations not yet seeded locally
logger.info(f"Fetching {book} {chapter} ({version}) from bible-api.com...")
fetched_verses = []
try:
import httpx
formatted_book = book.replace(" ", "+")
url = f"https://bible-api.com/{formatted_book}+{chapter}?translation={version.lower()}"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
if response.status_code == 200:
data = response.json()
for v in data.get("verses", []):
fetched_verses.append({
"book": book, "chapter": chapter,
"verse": v.get("verse"),
"text": v.get("text", "").strip(),
"version": version.upper(),
})
if fetched_verses:
self.save_bible_verses(fetched_verses)
return fetched_verses
except Exception:
logger.exception("Error fetching from external Bible API")
return []
def get_bible_chapter_local(self, book: str, chapter: int, version: str = "KJV") -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"SELECT * FROM bible_verses WHERE LOWER(book) = LOWER(%s) AND chapter = %s AND version = %s ORDER BY verse ASC",
(book, chapter, version.upper()),
)
return [dict(r) for r in cur.fetchall()]
def save_bible_verses(self, verses: List[Dict[str, Any]]):
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
for v in verses:
cur.execute(
"""INSERT INTO bible_verses (book, chapter, verse, text, version)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (book, chapter, verse, version) DO NOTHING""",
(v['book'], v['chapter'], v['verse'], v['text'], v.get('version', 'KJV').upper()),
)
logger.info(f"Cached {len(verses)} verses locally")
except Exception:
logger.exception("Error saving verses to cache")
def get_bible_verse(self, book: str, chapter: int, verse: int, version: str = "KJV") -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"SELECT * FROM bible_verses WHERE LOWER(book) = LOWER(%s) AND chapter = %s AND verse = %s AND version = %s",
(book, chapter, verse, version.upper()),
)
return self.to_dict(cur.fetchone())
def search_bible_verses(self, query: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM bible_verses WHERE text ILIKE %s", (f"%{query}%",))
return [self.to_dict(r) for r in cur.fetchall()]
def get_scripture_references(self, category: str) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM scripture_references WHERE category = %s", (category,))
return [self.to_dict(r) for r in cur.fetchall()]
# ==================== User Activity Operations ====================
def get_user_activity(self, user_id: str, limit: int = 5) -> List[Dict[str, Any]]:
activities = []
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"SELECT id, book, chapter, created_at FROM bible_study_sessions WHERE user_id = %s ORDER BY created_at DESC LIMIT %s",
(user_id, limit),
)
for row in cur.fetchall():
activities.append({
"type": "bible_study", "title": f"{row['book']} {row['chapter']}",
"subtitle": "Bible Study", "created_at": row['created_at'], "path": "/app/bible-study",
})
cur.execute(
"SELECT id, mood, created_at FROM emotional_support_sessions WHERE user_id = %s ORDER BY created_at DESC LIMIT %s",
(user_id, limit),
)
for row in cur.fetchall():
activities.append({
"type": "support", "title": row['mood'],
"subtitle": "Emotional Support", "created_at": row['created_at'], "path": "/app/emotional-support",
})
cur.execute(
"SELECT id, day_plan_summary, created_at FROM devotions WHERE user_id = %s ORDER BY created_at DESC LIMIT %s",
(user_id, limit),
)
for row in cur.fetchall():
activities.append({
"type": "devotion", "title": (row['day_plan_summary'] or "Daily Devotion")[:30],
"subtitle": "Devotion", "created_at": row['created_at'], "path": "/app/devotion",
})
cur.execute(
"SELECT id, title, created_at FROM ai_chat_sessions WHERE user_id = %s ORDER BY created_at DESC LIMIT %s",
(user_id, limit),
)
for row in cur.fetchall():
activities.append({
"type": "chat", "title": row['title'] or "Conversation",
"subtitle": "AI Chat", "created_at": row['created_at'], "path": "/app/ai-chat",
})
activities.sort(key=lambda x: x["created_at"], reverse=True)
return activities[:limit]
# ==================== Notes Operations ====================
def create_note(self, user_id: str, note_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = note_data.copy()
data['id'] = str(uuid.uuid4())
data['user_id'] = user_id
if 'tags' in data and not isinstance(data['tags'], str):
data['tags'] = json.dumps(data['tags'])
self._validate_columns("notes", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO notes ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM notes WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating note")
return None
def get_note(self, note_id: str, user_id: str) -> Optional[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM notes WHERE id = %s AND user_id = %s", (note_id, user_id))
return self.to_dict(cur.fetchone())
def get_notes(self, user_id: str, source_type: Optional[str] = None) -> List[Dict[str, Any]]:
with self.get_connection() as conn:
cur = self._cursor(conn)
if source_type:
cur.execute(
"SELECT * FROM notes WHERE user_id = %s AND source_type = %s ORDER BY created_at DESC",
(user_id, source_type),
)
else:
cur.execute("SELECT * FROM notes WHERE user_id = %s ORDER BY created_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
def delete_note(self, note_id: str, user_id: str) -> bool:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("DELETE FROM notes WHERE id = %s AND user_id = %s", (note_id, user_id))
return cur.rowcount > 0
except Exception:
logger.exception("Error deleting note")
return False
def verify_note_password(self, note_id: str, user_id: str, password: str) -> bool:
from auth import verify_password
note = self.get_note(note_id, user_id)
if not note or not note.get('password_hash'):
return False
return verify_password(password, note['password_hash'])
def update_note(self, note_id: str, user_id: str, note_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = note_data.copy()
if 'tags' in data and not isinstance(data['tags'], str):
data['tags'] = json.dumps(data['tags'])
self._validate_columns("notes", data.keys())
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
f"UPDATE notes SET {set_clause}, updated_at = NOW() WHERE id = %s AND user_id = %s",
list(data.values()) + [note_id, user_id],
)
cur.execute("SELECT * FROM notes WHERE id = %s", (note_id,))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error updating note")
return None
# ==================== Prayer Operations ====================
def create_prayer(self, prayer_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = prayer_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
self._validate_columns("prayers", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO prayers ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute("SELECT * FROM prayers WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating prayer")
return None
def delete_prayer(self, prayer_id: str, user_id: str) -> bool:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("DELETE FROM prayers WHERE id = %s AND user_id = %s", (prayer_id, user_id))
return cur.rowcount > 0
except Exception:
logger.exception("Error deleting prayer")
return False
def get_prayers(self, user_id: str) -> List[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM prayers WHERE user_id = %s ORDER BY created_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
except Exception:
logger.exception("Error getting prayers")
return []
# ==================== Dashboard Stats ====================
def get_user_stats(self, user_id: str) -> Dict[str, Any]:
stats = {"streak_days": 1, "time_today_minutes": 15, "total_reflections": 0, "streak_history": [False] * 7}
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT COUNT(*) AS count FROM notes WHERE user_id = %s", (user_id,))
stats["total_reflections"] = cur.fetchone()["count"]
today = datetime.now()
days_since_sunday = (today.weekday() + 1) % 7
sunday = today - timedelta(days=days_since_sunday)
history = []
for i in range(7):
day_check = sunday + timedelta(days=i)
day_str = day_check.strftime("%Y-%m-%d")
cur.execute(
"""SELECT (
EXISTS(SELECT 1 FROM devotions WHERE user_id = %s AND created_at::date = %s) OR
EXISTS(SELECT 1 FROM bible_study_sessions WHERE user_id = %s AND created_at::date = %s) OR
EXISTS(SELECT 1 FROM emotional_support_sessions WHERE user_id = %s AND created_at::date = %s) OR
EXISTS(SELECT 1 FROM notes WHERE user_id = %s AND created_at::date = %s)
) AS has_activity""",
(user_id, day_str, user_id, day_str, user_id, day_str, user_id, day_str),
)
history.append(bool(cur.fetchone()['has_activity']))
stats["streak_history"] = history
today_idx = (today.weekday() + 1) % 7
streak = 0
for i in range(today_idx, -1, -1):
if history[i]:
streak += 1
else:
break
stats["streak_days"] = streak
return stats
except Exception:
logger.exception("Error getting user stats")
return stats
# ==================== Cached Verse Operations ====================
def get_cached_verse(self, user_id: str, date_str: str) -> Optional[Dict[str, Any]]:
import json as _json
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"SELECT verse_text AS verse, verse_reference AS reference, aria_insight AS insight, daily_manna FROM cached_verses WHERE user_id = %s AND cached_date = %s",
(user_id, date_str),
)
row = cur.fetchone()
if not row:
return None
result = dict(row)
manna = result.get("daily_manna")
if manna and isinstance(manna, str):
try:
result["daily_manna"] = _json.loads(manna)
except _json.JSONDecodeError:
pass # leave as plain string (old cache rows)
return result
except Exception:
logger.exception("Error getting cached verse")
return None
def save_cached_verse(self, user_id: str, date_str: str, verse_data: Dict[str, Any]) -> bool:
import json as _json
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("DELETE FROM cached_verses WHERE user_id = %s", (user_id,))
manna = verse_data.get("daily_manna")
manna_str = _json.dumps(manna) if isinstance(manna, dict) else manna
cur.execute(
"INSERT INTO cached_verses (user_id, verse_text, verse_reference, aria_insight, daily_manna, cached_date) VALUES (%s, %s, %s, %s, %s, %s)",
(user_id, verse_data["verse"], verse_data["reference"], verse_data.get("insight"), manna_str, date_str),
)
return True
except Exception:
logger.exception("Error saving cached verse")
return False
# ==================== AI Chat Operations ====================
def create_chat_session(self, user_id: str, title: str = "New Conversation") -> Optional[Dict[str, Any]]:
try:
session_id = str(uuid.uuid4())
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"INSERT INTO ai_chat_sessions (id, user_id, title) VALUES (%s, %s, %s)",
(session_id, user_id, title),
)
return {"id": session_id, "user_id": user_id, "title": title}
except Exception:
logger.exception("Error creating chat session")
return None
def get_chat_session(self, session_id: str) -> Optional[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM ai_chat_sessions WHERE id = %s", (session_id,))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error getting chat session")
return None
def get_chat_sessions(self, user_id: str) -> List[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM ai_chat_sessions WHERE user_id = %s ORDER BY updated_at DESC", (user_id,))
return [self.to_dict(r) for r in cur.fetchall()]
except Exception:
logger.exception("Error getting chat sessions")
return []
def get_chat_session_messages(self, session_id: str) -> List[Dict[str, Any]]:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute("SELECT * FROM ai_chat_messages WHERE session_id = %s ORDER BY created_at ASC", (session_id,))
return [self.to_dict(r) for r in cur.fetchall()]
except Exception:
logger.exception("Error getting chat messages")
return []
def create_chat_message(self, message_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
data = message_data.copy()
if 'id' not in data:
data['id'] = str(uuid.uuid4())
self._validate_columns("ai_chat_messages", data.keys())
cols = ', '.join(data.keys())
placeholders = ', '.join(['%s'] * len(data))
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(f"INSERT INTO ai_chat_messages ({cols}) VALUES ({placeholders})", list(data.values()))
cur.execute(
"UPDATE ai_chat_sessions SET updated_at = NOW() WHERE id = %s",
(data['session_id'],),
)
cur.execute("SELECT * FROM ai_chat_messages WHERE id = %s", (data['id'],))
return self.to_dict(cur.fetchone())
except Exception:
logger.exception("Error creating chat message")
return None
def update_chat_session_title(self, session_id: str, title: str) -> bool:
try:
with self.get_connection() as conn:
cur = self._cursor(conn)
cur.execute(
"UPDATE ai_chat_sessions SET title = %s, updated_at = NOW() WHERE id = %s",
(title, session_id),
)
return cur.rowcount > 0
except Exception:
logger.exception("Error updating chat session title")
return False
db = Database()