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:
2025-12-29 23:48:14 +02:00
parent 2a101f1ef5
commit c5e051ad80
378 changed files with 7566 additions and 73730 deletions

View File

@@ -0,0 +1,768 @@
"""
Telegram bot handlers for email-based authentication flow
"""
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
filters
)
import logging
from datetime import datetime
import asyncio
from backend.modules.telegram.auth.email_auth import (
is_valid_email_format,
verify_email_in_oracle,
generate_email_code,
generate_session_token,
check_rate_limit,
clear_rate_limit
)
from backend.modules.telegram.utils.email_service import get_email_service
from backend.modules.telegram.db.operations import (
create_email_auth_code,
get_email_auth_code,
get_pending_email_code,
mark_email_code_used,
increment_failed_attempts,
delete_user_email_codes,
is_user_authenticated,
link_user_to_oracle,
create_or_update_user
)
from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
# Conversation states
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
# Constants
MAX_CODE_ATTEMPTS = 3
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
async def edit_login_message(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
text: str,
reply_markup=None,
parse_mode="Markdown"
):
"""
Helper function to edit the login message stored in context.
If message_id is not stored, creates a new message instead.
"""
message_id = context.user_data.get('login_message_id')
if message_id:
try:
await context.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
except Exception as e:
logger.warning(f"Could not edit message {message_id}: {e}")
# Fallback: send new message and update ID
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
context.user_data['login_message_id'] = msg.message_id
else:
# No message ID stored - create new message
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
context.user_data['login_message_id'] = msg.message_id
async def delete_login_message(context: ContextTypes.DEFAULT_TYPE, chat_id: int):
"""Delete the login message and clear the message_id from context"""
message_id = context.user_data.get('login_message_id')
if message_id:
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
logger.warning(f"Could not delete message {message_id}: {e}")
# Clear from context
context.user_data.pop('login_message_id', None)
# ============================================================================
# ENTRY POINTS: /login command and action:login button
# ============================================================================
async def login_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Handler pentru /login command
Oferă opțiuni de autentificare: Email sau Web App
"""
user = update.effective_user
# Check dacă e deja autentificat
if await is_user_authenticated(user.id):
await update.message.reply_text(
"Ești deja autentificat.\n\n"
"Folosește:\n"
"• /companies - Vezi companiile tale\n"
"• /help - Comenzi disponibile\n"
"• /unlink - Deautentifică-te"
)
return ConversationHandler.END
# Check rate limiting (3 requests per hour)
if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60):
await update.message.reply_text(
"Prea multe încercări de autentificare.\n\n"
"Te rugăm să aștepți 60 de minute înainte de a încerca din nou."
)
return ConversationHandler.END
# Afișează opțiuni de autentificare
keyboard = [
[InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")],
[InlineKeyboardButton("Login din Web App", callback_data="web_login_info")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# CREATE message and SAVE message_id
msg = await update.message.reply_text(
"Alege metoda de autentificare:",
reply_markup=reply_markup,
parse_mode="Markdown"
)
# Save message ID for future edits
context.user_data['login_message_id'] = msg.message_id
return AWAITING_EMAIL
async def action_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Handler pentru butonul Login din meniu (action:login)
Oferă opțiuni de autentificare: Email sau Web App
"""
query = update.callback_query
user = update.effective_user
logger.info(f"[EMAIL_AUTH] action_login_callback triggered for user {user.id}")
await query.answer()
# Check dacă e deja autentificat
if await is_user_authenticated(user.id):
await query.edit_message_text(
"Ești deja autentificat.\n\n"
"Folosește:\n"
"• /companies - Vezi companiile tale\n"
"• /help - Comenzi disponibile\n"
"• /unlink - Deautentifică-te"
)
return ConversationHandler.END
# Check rate limiting (3 requests per hour)
if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60):
await query.edit_message_text(
"Prea multe încercări de autentificare.\n\n"
"Te rugăm să aștepți 60 de minute înainte de a încerca din nou."
)
return ConversationHandler.END
# Afișează opțiuni de autentificare
keyboard = [
[InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")],
[InlineKeyboardButton("Login din Web App", callback_data="web_login_info")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# EDIT existing menu message and SAVE message_id
await query.edit_message_text(
"Alege metoda de autentificare:",
reply_markup=reply_markup,
parse_mode="Markdown"
)
# Save message ID for future edits
context.user_data['login_message_id'] = query.message.message_id
return AWAITING_EMAIL
# ============================================================================
# CALLBACK: Email Login
# ============================================================================
async def email_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback pentru butonul 'Login cu Email'"""
query = update.callback_query
user = update.effective_user
logger.info(f"[EMAIL_AUTH] email_login_callback triggered for user {user.id}")
await query.answer()
# IMPORTANT: Salvează message_id înainte de a edita
context.user_data['login_message_id'] = query.message.message_id
# EDIT same message - remove buttons, ask for email
await query.edit_message_text(
text="Introdu adresa de email ROA:",
parse_mode="Markdown"
)
return AWAITING_EMAIL
async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Info despre web app login"""
query = update.callback_query
await query.answer()
await query.edit_message_text(
"**Login din Web App**\n\n"
"Pentru această metodă:\n\n"
"1. Accesează aplicația web ROA2WEB\n"
"2. Autentifică-te cu username + parolă\n"
"3. Apasă butonul \"Link Telegram\"\n"
"4. Copiază codul generat (8 caractere)\n"
"5. Trimite-mi codul: /start ABC123XY\n\n"
"Vei fi autentificat automat.",
parse_mode="Markdown"
)
# IMPORTANT: Salvează message_id pentru ca /start să poată edita același mesaj
context.user_data['web_login_message_id'] = query.message.message_id
return ConversationHandler.END
# ============================================================================
# STATE: AWAITING_EMAIL
# ============================================================================
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
# Show error in main message
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Check for existing pending code
existing_code = await get_pending_email_code(user_id)
if existing_code:
# Delete old pending code
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare email...",
reply_markup=None
)
try:
# Verifică email în Oracle
username = await verify_email_in_oracle(email)
# IMPORTANT: Generic response to prevent email enumeration
# We always say "code sent" even if email doesn't exist
if username:
# Email exists - generate and send code
code = generate_email_code()
# Save code in database
code_saved = await create_email_auth_code(
code=code,
email=email,
username=username,
telegram_user_id=user_id,
expiry_minutes=5
)
if not code_saved:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Send email (async with retry)
email_service = get_email_service()
email_sent = await email_service.send_auth_code(email, code, username)
if not email_sent:
logger.error(f"Failed to send email to {email}")
# Don't reveal this to user - they'll timeout naturally
# Wait 1 second for better UX (looks like verification happened)
await asyncio.sleep(1)
# ALWAYS show this message (prevent enumeration)
# EDIT same message with success + buttons
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
# Save email in context for resend functionality
context.user_data['pending_email'] = email
context.user_data['pending_username'] = username
return AWAITING_CODE
except Exception as e:
logger.error(f"Error in receive_email: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare internă.\n\nIncearcă din nou mai târziu."
)
return ConversationHandler.END
# ============================================================================
# STATE: AWAITING_CODE
# ============================================================================
async def receive_code(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea codului din email"""
code = update.message.text.strip()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete code message: {e}")
# Validare format cod (6 digits)
if not (code.isdigit() and len(code) == 6):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid\n\nIntrodu cele 6 cifre din email.",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{context.user_data.get('pending_email', '')}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_CODE
# Verifică cod în DB
try:
code_data = await get_email_auth_code(code)
if not code_data:
# EDIT login message to show error
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid sau expirat\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Verificări de securitate
# 1. Check if already used
if code_data['used']:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod deja folosit\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# 2. Check if expired
if datetime.now() > code_data['expires_at']:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod expirat\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# 3. Check if belongs to this user
if code_data['telegram_user_id'] != user_id:
logger.warning(
f"User {user_id} tried to use code belonging to "
f"user {code_data['telegram_user_id']}"
)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid"
)
return ConversationHandler.END
# 4. Check failed attempts (max 3)
if code_data['failed_attempts'] >= MAX_CODE_ATTEMPTS:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Prea multe încercări greșite\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Cod valid - Marchează ca folosit
await mark_email_code_used(code)
# Salvează date verificate în context
context.user_data['verified_username'] = code_data['oracle_username']
context.user_data['verified_email'] = code_data['email']
context.user_data['session_token'] = generate_session_token(
user_id,
code_data['email']
)
# EDIT same message - ask for password
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod validat!\n\nIntroduci parola ROA:",
reply_markup=None # No buttons for security
)
return AWAITING_PASSWORD
except Exception as e:
logger.error(f"Error validating code: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la validarea codului.\n\nIncearcă din nou."
)
return ConversationHandler.END
async def resend_code_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Retrimite codul pe email"""
query = update.callback_query
await query.answer("Retrimitem codul...")
# Extract email from callback data
callback_data = query.data # Format: "resend:email@example.com"
if not callback_data.startswith("resend:"):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
email = callback_data.split(":", 1)[1]
user_id = update.effective_user.id
# Check rate limiting for resend (max 2 per 10 minutes)
if not await check_rate_limit(f"resend_{user_id}", max_attempts=2, window_minutes=10):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Prea multe solicitări\n\nAșteaptă 10 minute."
)
return ConversationHandler.END
# Get username from context or re-verify
username = context.user_data.get('pending_username')
if not username:
username = await verify_email_in_oracle(email)
if not username:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Delete old code and generate new one
await delete_user_email_codes(user_id)
code = generate_email_code()
# Save new code
await create_email_auth_code(
code=code,
email=email,
username=username,
telegram_user_id=user_id,
expiry_minutes=5
)
# Send email
email_service = get_email_service()
await email_service.send_auth_code(email, code, username)
# FIX BUG: EDIT message and KEEP buttons!
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=f"Cod retrimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_CODE
# ============================================================================
# STATE: AWAITING_PASSWORD
# ============================================================================
async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea parolei Oracle"""
password = update.message.text.strip()
user_id = update.effective_user.id
# Șterge IMEDIAT mesajul cu parola (securitate)
try:
await update.message.delete()
logger.info(f"Password message deleted for user {user_id}")
except Exception as e:
logger.warning(f"Could not delete password message: {e}")
# Get verified data from context
username = context.user_data.get('verified_username')
email = context.user_data.get('verified_email')
session_token = context.user_data.get('session_token')
if not all([username, email, session_token]):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Sesiune expirată\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare...",
reply_markup=None
)
try:
# Call backend endpoint pentru verificare parolă + JWT
backend_client = get_backend_client()
response = await backend_client.login_with_email(
email=email,
password=password,
telegram_user_id=user_id,
session_token=session_token
)
if not response.get('success'):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Credențiale invalide\n\nParolă incorectă sau cont inactiv.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Success - Salvează user în telegram_users
# First create or update user record
await create_or_update_user(
telegram_user_id=user_id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name
)
# Then link to Oracle
from datetime import datetime, timedelta
token_expires_at = datetime.now() + timedelta(minutes=30) # Default expiry
await link_user_to_oracle(
telegram_user_id=user_id,
oracle_username=response['username'],
jwt_token=response['access_token'],
jwt_refresh_token=response['refresh_token'],
token_expires_at=token_expires_at
)
# Clear rate limits on successful auth
clear_rate_limit(f"login_{user_id}")
clear_rate_limit(f"resend_{user_id}")
# Get session and active company BEFORE editing message
from backend.modules.telegram.agent.session import get_session_manager
from backend.modules.telegram.bot.menus import create_main_menu, pad_message_for_wide_buttons
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(user_id)
company = session.get_active_company()
company_name = company['name'] if company else None
company_cui = company.get('cui') if company else None
# Create main menu keyboard
keyboard = create_main_menu(
company_name=company_name,
company_cui=company_cui,
is_authenticated=True, # Now authenticated
cache_enabled=True # Default enabled
)
# Menu message with company info
companies_count = len(response.get('companies', []))
if company_name:
menu_text = f"{company_name}"
else:
menu_text = f"Companii disponibile: {companies_count}\n\nSelectează o companie pentru a continua"
menu_message = pad_message_for_wide_buttons(menu_text)
# EDIT login message to show menu (no deletion, direct edit)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=menu_message,
reply_markup=keyboard
)
# Clear sensitive data from context
context.user_data.clear()
logger.info(f"User {user_id} authenticated successfully via email")
return ConversationHandler.END
except Exception as e:
logger.error(f"Error during password verification: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la autentificare.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# ============================================================================
# CANCEL HANDLER
# ============================================================================
async def cancel_login(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Cancel conversation"""
# EDIT login message to show cancellation (don't delete)
if update.callback_query:
# Called from button
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Login anulat",
reply_markup=None
)
elif update.message:
# Called from /cancel command
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Login anulat",
reply_markup=None
)
# Clear context
context.user_data.clear()
return ConversationHandler.END
async def conversation_timeout(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler for conversation timeout"""
# EDIT login message to show timeout
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Sesiune expirată\n\nConversația a expirat după 5 minute.\n\nIncearcă din nou cu /login"
)
# Clear context
context.user_data.clear()
return ConversationHandler.END
# ============================================================================
# CONVERSATION HANDLER SETUP
# ============================================================================
email_login_handler = ConversationHandler(
entry_points=[
CommandHandler('login', login_command),
CallbackQueryHandler(action_login_callback, pattern='^action:login$'),
CallbackQueryHandler(email_login_callback, pattern='^email_login$')
],
states={
AWAITING_EMAIL: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
],
AWAITING_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
CallbackQueryHandler(resend_code_callback, pattern='^resend:')
],
AWAITING_PASSWORD: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_password)
],
},
fallbacks=[
CommandHandler('cancel', cancel_login),
CallbackQueryHandler(cancel_login, pattern='^cancel$'),
CallbackQueryHandler(web_login_info_callback, pattern='^web_login_info$')
],
per_message=False, # Track conversation per user, not per message
allow_reentry=True, # Allow starting new conversation even if previous one is active
name="email_login_conversation"
)