feat: Migrate to ultrathin monolith architecture
Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:
Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration
Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication
Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management
Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/
This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
310
backend/modules/telegram/db/database.py
Normal file
310
backend/modules/telegram/db/database.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
SQLite Database Setup for Telegram Bot
|
||||
|
||||
This module handles database connection, initialization, and schema creation.
|
||||
Uses aiosqlite for async SQLite operations.
|
||||
"""
|
||||
|
||||
import aiosqlite
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database file location
|
||||
DB_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
DB_PATH = DB_DIR / "telegram_bot.db"
|
||||
|
||||
|
||||
async def get_db_connection() -> aiosqlite.Connection:
|
||||
"""
|
||||
Get a database connection.
|
||||
|
||||
Returns:
|
||||
aiosqlite.Connection: Database connection
|
||||
"""
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
conn.row_factory = aiosqlite.Row # Enable column access by name
|
||||
return conn
|
||||
|
||||
|
||||
async def init_database() -> None:
|
||||
"""
|
||||
Initialize the database and create all tables.
|
||||
Safe to call multiple times - only creates tables if they don't exist.
|
||||
"""
|
||||
try:
|
||||
# Ensure data directory exists
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Database directory: {DB_DIR}")
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
# Enable foreign keys
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Create telegram_users table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
telegram_user_id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
oracle_username TEXT,
|
||||
jwt_token TEXT,
|
||||
jwt_refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP,
|
||||
linked_at TIMESTAMP,
|
||||
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
)
|
||||
""")
|
||||
|
||||
# Create telegram_auth_codes table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
telegram_user_id INTEGER,
|
||||
oracle_username TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT 0,
|
||||
used_at TIMESTAMP,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create telegram_sessions table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
telegram_user_id INTEGER NOT NULL,
|
||||
conversation_state TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create email_auth_codes table (email-based authentication)
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
oracle_username TEXT NOT NULL,
|
||||
telegram_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
used_at TIMESTAMP,
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better query performance
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_telegram_user
|
||||
ON telegram_auth_codes(telegram_user_id)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_expires
|
||||
ON telegram_auth_codes(expires_at)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_telegram_user
|
||||
ON telegram_sessions(telegram_user_id)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires
|
||||
ON telegram_sessions(expires_at)
|
||||
""")
|
||||
|
||||
# Create indexes for email_auth_codes table
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_auth_email
|
||||
ON email_auth_codes(email)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_auth_telegram_user
|
||||
ON email_auth_codes(telegram_user_id)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_auth_expires
|
||||
ON email_auth_codes(expires_at)
|
||||
""")
|
||||
|
||||
await db.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Log table info
|
||||
cursor = await db.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
ORDER BY name
|
||||
""")
|
||||
tables = await cursor.fetchall()
|
||||
logger.info(f"Existing tables: {[t[0] for t in tables]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_expired_codes() -> int:
|
||||
"""
|
||||
Delete expired authentication codes from the database.
|
||||
This should be called periodically (e.g., every hour).
|
||||
|
||||
Returns:
|
||||
int: Number of expired codes deleted
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM telegram_auth_codes
|
||||
WHERE expires_at < ?
|
||||
""", (datetime.now(),))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired auth codes")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired codes: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def cleanup_expired_sessions() -> int:
|
||||
"""
|
||||
Delete expired sessions from the database.
|
||||
This should be called periodically (e.g., daily).
|
||||
|
||||
Returns:
|
||||
int: Number of expired sessions deleted
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM telegram_sessions
|
||||
WHERE expires_at < ?
|
||||
""", (datetime.now(),))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired sessions")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired sessions: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def cleanup_expired_email_codes() -> int:
|
||||
"""
|
||||
Delete expired and old used email codes from the database.
|
||||
This should be called periodically (e.g., hourly).
|
||||
|
||||
Returns:
|
||||
int: Number of email codes deleted
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
# Delete expired codes or used codes older than 1 day
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM email_auth_codes
|
||||
WHERE expires_at < ?
|
||||
OR (used = 1 AND used_at < ?)
|
||||
""", (
|
||||
datetime.now(),
|
||||
datetime.now() - timedelta(days=1)
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired/old email auth codes")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup email codes: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def get_database_stats() -> dict:
|
||||
"""
|
||||
Get database statistics for monitoring.
|
||||
|
||||
Returns:
|
||||
dict: Database statistics
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
stats = {}
|
||||
|
||||
# Count users
|
||||
cursor = await db.execute("SELECT COUNT(*) FROM telegram_users")
|
||||
stats['total_users'] = (await cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) FROM telegram_users WHERE is_active = 1"
|
||||
)
|
||||
stats['active_users'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Count pending codes
|
||||
cursor = await db.execute("""
|
||||
SELECT COUNT(*) FROM telegram_auth_codes
|
||||
WHERE used = 0 AND expires_at > ?
|
||||
""", (datetime.now(),))
|
||||
stats['pending_codes'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Count active sessions
|
||||
cursor = await db.execute("""
|
||||
SELECT COUNT(*) FROM telegram_sessions
|
||||
WHERE expires_at > ?
|
||||
""", (datetime.now(),))
|
||||
stats['active_sessions'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Database file size
|
||||
if DB_PATH.exists():
|
||||
stats['db_size_mb'] = DB_PATH.stat().st_size / (1024 * 1024)
|
||||
else:
|
||||
stats['db_size_mb'] = 0
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get database stats: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# Export main functions
|
||||
__all__ = [
|
||||
'get_db_connection',
|
||||
'init_database',
|
||||
'cleanup_expired_codes',
|
||||
'cleanup_expired_sessions',
|
||||
'cleanup_expired_email_codes',
|
||||
'get_database_stats',
|
||||
'DB_PATH',
|
||||
]
|
||||
Reference in New Issue
Block a user