diff --git a/.claude/handover-cleanup-roa2web.md b/.claude/handover-cleanup-roa2web.md deleted file mode 100644 index b3cc83a..0000000 --- a/.claude/handover-cleanup-roa2web.md +++ /dev/null @@ -1,69 +0,0 @@ -# Handover: Curățare Cod ROA2WEB - -## Context - -Am analizat arhitectura ROA2WEB cu 3 agenți de explorare și am identificat 4 acțiuni de curățare cu randament mare și risc minim. - -**Plan detaliat**: `/home/claude/.claude/plans/immutable-chasing-flute.md` - -## Prompt pentru /ralph:prd - -``` -Implementează curățarea codului ROA2WEB conform planului din /home/claude/.claude/plans/immutable-chasing-flute.md - -Obiective: -1. Elimină store duplication - șterge src/modules/reports/stores/sharedStores.js și src/modules/data-entry/stores/sharedStores.js, mută instantierea în App.vue -2. Creează factory pentru API services - src/shared/services/createApiService.js și simplifică api.js din module -3. Fă dependențele OCR opționale - adaugă variabile în .env (OCR_ENABLE_PADDLEOCR, OCR_ENABLE_TESSERACT), implementează lazy loading în OCR service, mută deps în requirements opțional -4. Consolidează CSS design tokens - unifică variables.css, tokens.css, md3-tokens.css într-un singur design-tokens.css - -Constrângeri: -- NU schimba arhitectura (layered architecture rămâne) -- NU modifica auth middleware, cache decorator, oracle pool -- Testează după fiecare modificare: app pornește, login funcționează, dark mode arată corect - -Ordinea execuției: -1. [15 min] Store duplication -2. [30 min] API factory -3. [30 min] OCR dependencies opționale (lazy loading via .env) -4. [1 oră] CSS tokens - -Impact așteptat: -150 linii cod duplicat, OCR deps opționale via .env, -2 fișiere CSS, startup mai rapid -``` - -## Fișiere Cheie de Referință - -### De Șters -- `src/modules/reports/stores/sharedStores.js` -- `src/modules/data-entry/stores/sharedStores.js` - -### De Creat -- `src/shared/services/createApiService.js` -- `src/assets/css/core/design-tokens.css` (consolidat) - -### De Modificat -- `src/App.vue` - adaugă instantiere stores -- `src/modules/reports/services/api.js` - simplifică cu factory -- `src/modules/data-entry/services/api.js` - simplifică cu factory -- `backend/requirements.txt` - mută paddleocr/tesseract în secțiune opțională (comentate) -- `backend/.env` - adaugă `OCR_ENABLE_PADDLEOCR=false` și `OCR_ENABLE_TESSERACT=false` -- `backend/modules/data_entry/services/ocr_service.py` - implementează lazy loading pentru OCR engines -- `src/assets/css/main.css` - actualizează importuri CSS - -## Verificare Finală - -După implementare, verifică: -- [ ] `./start.sh prod` pornește fără erori -- [ ] Login funcționează -- [ ] Un raport se încarcă corect -- [ ] O chitanță se poate crea -- [ ] Dark mode arată corect pe toate paginile -- [ ] `pip install -r backend/requirements.txt` reușește -- [ ] Cu `OCR_ENABLE_PADDLEOCR=false` - app pornește fără PaddleOCR instalat -- [ ] Cu `OCR_ENABLE_PADDLEOCR=true` - OCR fallback funcționează (dacă e instalat) - -## Note - -- Arhitectura actuală (Layered) este potrivită pentru echipa de 1-2 developeri -- Nu este nevoie de Vertical Slice sau Feature-Sliced Design -- Acțiunile opționale (split receiptStore, simplificare cache) pot fi făcute ulterior diff --git a/backend/modules/reports/models/dashboard.py b/backend/modules/reports/models/dashboard.py index f13bf43..649e99b 100644 --- a/backend/modules/reports/models/dashboard.py +++ b/backend/modules/reports/models/dashboard.py @@ -2,6 +2,22 @@ from pydantic import BaseModel from decimal import Decimal from typing import List, Dict, Optional, Any + +class BudgetDebtSubAccount(BaseModel): + """Cont individual din cadrul unui grup de datorii buget""" + cont: str # ex: "4311" + label: str # ex: "4311 - CAS angajat" + precedent: Decimal # sold luna precedentă (pozitiv=datorie, negativ=creanță) + curent: Decimal # sold luna curentă (pozitiv=datorie, negativ=creanță) + +class BudgetDebtGroup(BaseModel): + """Grup de datorii la buget (TVA / BASS / CAM)""" + key: str # 'TVA', 'BASS', 'CAM' + label: str # 'TVA', 'BASS', 'CAM' + precedent: Decimal # total grup luna prec (semn ±) + curent: Decimal # total grup luna crt (semn ±) + sub_accounts: List[BudgetDebtSubAccount] = [] + class TreasuryAccount(BaseModel): """Cont de trezorerie (bancă/casă)""" cont: str # 5121, 5124, 5311, 5314 @@ -126,4 +142,8 @@ class DashboardSummary(BaseModel): tva_plata_precedent: Decimal = Decimal('0') tva_recuperat_precedent: Decimal = Decimal('0') tva_plata_curent: Decimal = Decimal('0') - tva_recuperat_curent: Decimal = Decimal('0') \ No newline at end of file + tva_recuperat_curent: Decimal = Decimal('0') + + # DATORII LA BUGET - breakdown pe grupe (TVA / BASS / CAM) cu sub-conturi + budget_debt_breakdown: List[BudgetDebtGroup] = [] + budget_debt_total_precedent: Decimal = Decimal('0') # suma tuturor grupurilor luna prec \ No newline at end of file diff --git a/backend/modules/reports/services/dashboard_service.py b/backend/modules/reports/services/dashboard_service.py index 73736a6..8fa6680 100644 --- a/backend/modules/reports/services/dashboard_service.py +++ b/backend/modules/reports/services/dashboard_service.py @@ -2,7 +2,7 @@ import os from shared.database.oracle_pool import oracle_pool -from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData +from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData, BudgetDebtGroup, BudgetDebtSubAccount from ..cache.decorators import cached from decimal import Decimal from typing import Dict, Any, List, Optional @@ -12,6 +12,45 @@ import logging logger = logging.getLogger(__name__) +# Grupuri de conturi pentru Datorii la Buget (3 grupe: TVA / BASS / CAM) +BUDGET_GROUPS = [ + { + 'key': 'TVA', + 'label': 'TVA', + 'accounts': ['4423', '4424'], + }, + { + 'key': 'BASS', + 'label': 'BASS', + 'accounts': ['431', '4311', '4313', '4315', '4316', '437', '444', '4411', '4418', '446', '447'], + }, + { + 'key': 'CAM', + 'label': 'CAM', + 'accounts': ['436'], + }, +] + +ACCOUNT_LABELS = { + '4311': '4311 - CAS angajat', + '4313': '4313 - CAS accidente muncă', + '4315': '4315 - CAS suplimentar', + '4316': '4316 - CASS angajat', + '431': '431 - CAS (cont total)', + '437': '437 - CASS (cont total)', + '444': '444 - Impozit salarii', + '4411': '4411 - Impozit pe profit', + '4418': '4418 - Impozit amânat', + '446': '446 - Alte impozite și taxe', + '447': '447 - Fonduri speciale', + '4423': '4423 - TVA de plată', + '4424': '4424 - TVA de recuperat', + '4426': '4426 - TVA deductibilă', + '4427': '4427 - TVA colectată', + '436': '436 - CAM', +} + + class DashboardService: """Service pentru dashboard - date agregate""" @@ -412,13 +451,17 @@ class DashboardService: AND ((cont IN ('5121','5311') AND soldcred - solddeb != 0) OR (cont IN ('5124','5314') AND soldvalcred - soldvaldeb != 0)) GROUP BY cont, nume, nume_val - ORDER BY cont, nume + ORDER BY + CASE WHEN cont IN ('5121','5311') THEN 0 ELSE 1 END, + cont, + UPPER(nume) """ cursor.execute(treasury_query, period_params) treasury_rows = cursor.fetchall() - # Query 3: Solduri TVA din tabelul vbal (folosește același period_cte) + # Query 3: Solduri datorii buget din tabelul vbal (43xx + 44xx) + # Extins față de TVA-only pentru a include CAS, CASS, impozite, etc. tva_query = f""" {period_cte} SELECT @@ -426,34 +469,42 @@ class DashboardService: precdeb, preccred, ruldeb, - rulcred + rulcred, + solddeb, + soldcred FROM {schema}.vbal WHERE an = (SELECT anul FROM luna_curenta) AND luna = (SELECT luna FROM luna_curenta) - AND cont IN ('4423', '4424', '4426', '4427') + AND (cont LIKE '43%' OR cont LIKE '44%') ORDER BY cont """ cursor.execute(tva_query, period_params) tva_rows = cursor.fetchall() - # Procesare solduri TVA - tva_data = { - '4423': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')}, - '4424': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')}, - '4426': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')}, - '4427': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')} - } + # Procesare solduri: dict generic pentru toate conturile 43xx/44xx + _zero = {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0'), 'solddeb': Decimal('0'), 'soldcred': Decimal('0')} + all_budget_data: Dict[str, Any] = {} for row in tva_rows: cont = row[0] - if cont in tva_data: - tva_data[cont]['precdeb'] = Decimal(str(row[1] or 0)) - tva_data[cont]['preccred'] = Decimal(str(row[2] or 0)) - tva_data[cont]['ruldeb'] = Decimal(str(row[3] or 0)) - tva_data[cont]['rulcred'] = Decimal(str(row[4] or 0)) + all_budget_data[cont] = { + 'precdeb': Decimal(str(row[1] or 0)), + 'preccred': Decimal(str(row[2] or 0)), + 'ruldeb': Decimal(str(row[3] or 0)), + 'rulcred': Decimal(str(row[4] or 0)), + 'solddeb': Decimal(str(row[5] or 0)), + 'soldcred': Decimal(str(row[6] or 0)), + } + + # Backward compat: tva_data dict cu doar conturile TVA clasice + tva_data = { + k: all_budget_data.get(k, dict(_zero)) + for k in ['4423', '4424', '4426', '4427'] + } # Calcul TVA Luna Precedentă - FIE de plată (4423) FIE de recuperat (4424) + # Primary: folosim soldurile conturilor de regularizare TVA (luna anterioară închisă) sold_4423 = tva_data['4423']['preccred'] - tva_data['4423']['precdeb'] sold_4424 = tva_data['4424']['precdeb'] - tva_data['4424']['preccred'] @@ -464,18 +515,97 @@ class DashboardService: tva_recuperat_precedent = sold_4424 tva_plata_precedent = Decimal('0') else: - tva_plata_precedent = Decimal('0') - tva_recuperat_precedent = Decimal('0') + # Fallback: când luna anterioară nu e închisă (4423/4424 = 0), + # calculăm soldul lunar net al conturilor 4427/4426 + sold_4427_prec = tva_data['4427']['preccred'] - tva_data['4427']['precdeb'] + sold_4426_prec = tva_data['4426']['precdeb'] - tva_data['4426']['preccred'] + diferenta_prec = sold_4427_prec - sold_4426_prec + if diferenta_prec > 0: + tva_plata_precedent = diferenta_prec + tva_recuperat_precedent = Decimal('0') + else: + tva_recuperat_precedent = -diferenta_prec + tva_plata_precedent = Decimal('0') - # Calcul TVA Luna Curentă - FIE de plată FIE de recuperat - diferenta_curent = tva_data['4427']['rulcred'] - tva_data['4426']['ruldeb'] + # Calcul TVA Luna Curentă - rulaj lunar 4423/4424 (nu sold cumulat) + sold_4423_cur = tva_data['4423']['rulcred'] - tva_data['4423']['ruldeb'] + sold_4424_cur = tva_data['4424']['ruldeb'] - tva_data['4424']['rulcred'] - if diferenta_curent > 0: - tva_plata_curent = diferenta_curent + if sold_4423_cur > 0: + tva_plata_curent = sold_4423_cur tva_recuperat_curent = Decimal('0') - else: - tva_recuperat_curent = -diferenta_curent + elif sold_4424_cur > 0: + tva_recuperat_curent = sold_4424_cur tva_plata_curent = Decimal('0') + else: + # Fallback: rulaj lunar net 4427/4426 (ruldeb/rulcred = doar luna curentă) + sold_4427_cur = tva_data['4427']['rulcred'] - tva_data['4427']['ruldeb'] + sold_4426_cur = tva_data['4426']['ruldeb'] - tva_data['4426']['rulcred'] + diferenta_curent = sold_4427_cur - sold_4426_cur + if diferenta_curent > 0: + tva_plata_curent = diferenta_curent + tva_recuperat_curent = Decimal('0') + else: + tva_recuperat_curent = -diferenta_curent + tva_plata_curent = Decimal('0') + + # Calcul Datorii la Buget - 3 grupe (TVA / BASS / CAM) cu sub-conturi + budget_debt_breakdown = [] + for group_def in BUDGET_GROUPS: + sub_accounts = [] + group_prec = Decimal('0') + group_cur = Decimal('0') + + if group_def['key'] == 'TVA': + # TVA special: valorile deja calculate (semn ±) + tva_prec = tva_plata_precedent - tva_recuperat_precedent + tva_cur = tva_plata_curent - tva_recuperat_curent + group_prec = tva_prec + group_cur = tva_cur + # Sub-conturi TVA (doar cele cu sold non-zero) + for cont in ['4423', '4424', '4426', '4427']: + if cont in all_budget_data: + d = all_budget_data[cont] + if cont == '4424': # creanță → semn negativ + val_prec = -(d['precdeb'] - d['preccred']) + val_cur = -(d['ruldeb'] - d['rulcred']) + else: # 4423, 4426, 4427: rulcred - ruldeb (lunar, nu cumulat) + val_prec = d['preccred'] - d['precdeb'] + val_cur = d['rulcred'] - d['ruldeb'] + if val_prec != 0 or val_cur != 0: + sub_accounts.append(BudgetDebtSubAccount( + cont=cont, + label=ACCOUNT_LABELS[cont], + precedent=val_prec, + curent=val_cur, + )) + else: + # BASS și CAM: matching exact pe codul contului + for account_code in group_def['accounts']: + if account_code in all_budget_data: + d = all_budget_data[account_code] + val_prec = d['preccred'] - d['precdeb'] # pozitiv = datorie + val_cur = d['rulcred'] - d['ruldeb'] # rulaj luna curentă (consistent cu TVA) + if val_prec != 0 or val_cur != 0: + sub_accounts.append(BudgetDebtSubAccount( + cont=account_code, + label=ACCOUNT_LABELS.get(account_code, account_code), + precedent=val_prec, + curent=val_cur, + )) + group_prec += val_prec + group_cur += val_cur + + if group_prec != 0 or group_cur != 0 or sub_accounts: + budget_debt_breakdown.append(BudgetDebtGroup( + key=group_def['key'], + label=group_def['label'], + precedent=group_prec, + curent=group_cur, + sub_accounts=sub_accounts, + )) + + budget_debt_total_precedent = sum(g.precedent for g in budget_debt_breakdown) # Procesare trezorerie treasury_accounts = [] @@ -562,7 +692,11 @@ class DashboardService: tva_plata_precedent=tva_plata_precedent, tva_recuperat_precedent=tva_recuperat_precedent, tva_plata_curent=tva_plata_curent, - tva_recuperat_curent=tva_recuperat_curent + tva_recuperat_curent=tva_recuperat_curent, + + # Datorii la buget pe grupe cu sub-conturi + budget_debt_breakdown=budget_debt_breakdown, + budget_debt_total_precedent=budget_debt_total_precedent, ) @staticmethod @@ -1691,7 +1825,9 @@ class DashboardService: AND vb.luna = lc.luna AND vb.cont IN ('5311', '5314', '5328', '5121', '5124') AND (vb.solddeb - vb.soldcred) <> 0 - ORDER BY tip, vb.cont + ORDER BY tip, + CASE WHEN vb.cont IN ('5121','5311') THEN 0 ELSE 1 END, + UPPER(vb.nume) """ cursor.execute(treasury_query, period_params) diff --git a/backend/modules/telegram/api/client.py b/backend/modules/telegram/api/client.py index 77f10e0..2602807 100644 --- a/backend/modules/telegram/api/client.py +++ b/backend/modules/telegram/api/client.py @@ -105,7 +105,8 @@ class BackendAPIClient: async def verify_user( self, oracle_username: str, - linking_code: str + linking_code: str, + server_id: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Verify user exists in Oracle and get JWT token. @@ -139,7 +140,8 @@ class BackendAPIClient: "/api/telegram/auth/verify-user", json={ "linking_code": linking_code, - "oracle_username": oracle_username + "oracle_username": oracle_username, + "server_id": server_id } ) @@ -185,12 +187,13 @@ class BackendAPIClient: logger.error(f"Failed to refresh token: {e}") return None - async def verify_email(self, email: str) -> dict: + async def verify_email(self, email: str, server_id: Optional[str] = None) -> dict: """ Verify if email exists in Oracle database Args: email: Email address to verify + server_id: Optional Oracle server ID (for multi-server mode) Returns: dict with 'success' (bool), 'username' (str or None), and 'message' (str) @@ -204,7 +207,7 @@ class BackendAPIClient: response = await self.client.post( "/api/telegram/auth/verify-email", - json={"email": email} + json={"email": email, "server_id": server_id} ) response.raise_for_status() return response.json() @@ -229,7 +232,8 @@ class BackendAPIClient: email: str, password: str, telegram_user_id: int, - session_token: str + session_token: str, + server_id: Optional[str] = None ) -> dict: """ Login via email + password with session token @@ -239,6 +243,7 @@ class BackendAPIClient: password: Oracle password telegram_user_id: Telegram user ID session_token: Signed token from code validation + server_id: Optional Oracle server ID (for multi-server mode) Returns: Login response with JWT tokens and user data @@ -256,7 +261,8 @@ class BackendAPIClient: "email": email, "password": password, "telegram_user_id": telegram_user_id, - "session_token": session_token + "session_token": session_token, + "server_id": server_id }, timeout=30.0 # 30 seconds timeout ) @@ -298,6 +304,52 @@ class BackendAPIClient: "message": "Eroare de conexiune" } + async def switch_server( + self, + jwt_token: str, + oracle_username: str, + new_server_id: str, + oracle_password: str = None + ) -> dict: + """ + Switch the active Oracle server for the authenticated user. + + Args: + jwt_token: Current JWT access token (used for authentication) + oracle_username: Oracle username of the current user + new_server_id: Target Oracle server ID + oracle_password: Oracle password on the new server (required if servers have different passwords) + + Returns: + Dict with success, access_token, refresh_token, message + """ + try: + if not self.client or self.client.is_closed: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + payload = {"oracle_username": oracle_username, "new_server_id": new_server_id} + if oracle_password: + payload["oracle_password"] = oracle_password + + response = await self.client.post( + "/api/telegram/auth/switch-server", + json=payload, + headers=self._get_auth_headers(jwt_token) + ) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + logger.error(f"Switch server HTTP error: {e.response.status_code}") + try: + return {"success": False, "message": e.response.json().get("detail", "Eroare")} + except Exception: + return {"success": False, "message": "Eroare la schimbarea serverului"} + + except Exception as e: + logger.error(f"Switch server error: {e}") + return {"success": False, "message": "Eroare de conexiune"} + async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]: """ Get list of companies the user has access to. diff --git a/backend/modules/telegram/auth/email_auth.py b/backend/modules/telegram/auth/email_auth.py index bc31a26..892ad13 100644 --- a/backend/modules/telegram/auth/email_auth.py +++ b/backend/modules/telegram/auth/email_auth.py @@ -101,12 +101,13 @@ def is_valid_email_format(email: str) -> bool: return bool(re.match(pattern, email)) -async def verify_email_in_oracle(email: str) -> Optional[str]: +async def verify_email_in_oracle(email: str, server_id: Optional[str] = None) -> Optional[str]: """ Verify email exists in Oracle UTILIZATORI table via backend API Args: email: Email address to check + server_id: Optional Oracle server ID (for multi-server mode) Returns: Oracle username if found and active, None otherwise @@ -118,8 +119,8 @@ async def verify_email_in_oracle(email: str) -> Optional[str]: backend_client = get_backend_client() - # Call backend API to verify email - response = await backend_client.verify_email(email) + # Call backend API to verify email (on specific server if provided) + response = await backend_client.verify_email(email, server_id=server_id) if response.get('success'): username = response.get('username') diff --git a/backend/modules/telegram/auth/linking.py b/backend/modules/telegram/auth/linking.py index c958341..8295b17 100644 --- a/backend/modules/telegram/auth/linking.py +++ b/backend/modules/telegram/auth/linking.py @@ -78,7 +78,8 @@ async def link_telegram_account( return None oracle_username = code_data.get('oracle_username') - logger.info(f"Auth code valid for Oracle user: {oracle_username}") + server_id = code_data.get('server_id') # Extract server_id from the stored code + logger.info(f"Auth code valid for Oracle user: {oracle_username} (server_id={server_id})") # Step 2: Create/update Telegram user record (basic info) user_created = await create_or_update_user( @@ -97,7 +98,8 @@ async def link_telegram_account( async with backend_client: user_data = await backend_client.verify_user( oracle_username=oracle_username, - linking_code=auth_code + linking_code=auth_code, + server_id=server_id ) if not user_data or not user_data.get('success'): diff --git a/backend/modules/telegram/bot/email_handlers.py b/backend/modules/telegram/bot/email_handlers.py index bb11db1..8d799a1 100644 --- a/backend/modules/telegram/bot/email_handlers.py +++ b/backend/modules/telegram/bot/email_handlers.py @@ -14,6 +14,8 @@ import logging from datetime import datetime import asyncio +from typing import Optional + from backend.modules.telegram.auth.email_auth import ( is_valid_email_format, verify_email_in_oracle, @@ -22,6 +24,7 @@ from backend.modules.telegram.auth.email_auth import ( check_rate_limit, clear_rate_limit ) +from shared.auth.email_server_cache import email_server_cache from backend.modules.telegram.utils.email_service import get_email_service from backend.modules.telegram.db.operations import ( create_email_auth_code, @@ -39,7 +42,7 @@ from backend.modules.telegram.api.client import get_backend_client logger = logging.getLogger(__name__) # Conversation states -AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3) +AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD, AWAITING_SERVER_SELECTION = range(4) # Constants MAX_CODE_ATTEMPTS = 3 @@ -261,57 +264,25 @@ async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_ # 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) +async def _send_email_code( + context: ContextTypes.DEFAULT_TYPE, + chat_id: int, + email: str, + server_id: Optional[str], + user_id: int +) -> int: + """ + Generate and send email verification code on the specified server. + Returns AWAITING_CODE on success or ConversationHandler.END on failure. + """ 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) + # Verify email in Oracle (on specific server if known) + username = await verify_email_in_oracle(email, server_id=server_id) # 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, @@ -323,27 +294,26 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE): if not code_saved: await edit_login_message( context=context, - chat_id=update.effective_chat.id, + chat_id=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 + if email_sent: + logger.info(f"[EMAIL-AUTH] ✅ Code sent for {email[:3]}***@*** (user {user_id}, server={server_id})") + else: + logger.error(f"[EMAIL-AUTH] ❌ Failed to send code (user {user_id}, server={server_id})") - # Wait 1 second for better UX (looks like verification happened) + # Wait 1 second for UX (looks like verification happened) await asyncio.sleep(1) - # ALWAYS show this message (prevent enumeration) - # EDIT same message with success + buttons + # ALWAYS show success (prevent enumeration) await edit_login_message( context=context, - chat_id=update.effective_chat.id, + chat_id=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}")], @@ -351,22 +321,135 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE): ]) ) - # 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) + logger.error(f"Error sending email code: {e}", exc_info=True) await edit_login_message( context=context, - chat_id=update.effective_chat.id, + chat_id=chat_id, text="Eroare internă.\n\nIncearcă din nou mai târziu." ) return ConversationHandler.END +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 + chat_id = update.effective_chat.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): + await edit_login_message( + context=context, + chat_id=chat_id, + text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("Anulează", callback_data="cancel")] + ]) + ) + return AWAITING_EMAIL + + # Clean up old pending codes + existing_code = await get_pending_email_code(user_id) + if existing_code: + await delete_user_email_codes(user_id) + logger.info(f"Deleted existing pending code for user {user_id}") + + # Show loading + await edit_login_message( + context=context, + chat_id=chat_id, + text="Verificare email...", + reply_markup=None + ) + + # Check server cache for multi-server routing + try: + await email_server_cache.refresh_if_needed() + servers = email_server_cache.get_servers_for_email(email) + except Exception as e: + logger.warning(f"Could not check email server cache: {e}") + servers = [] + + if len(servers) > 1: + # Multiple servers — ask user to select before sending code + context.user_data['pending_email'] = email + + try: + from backend.config import settings + keyboard = [] + for srv_id in servers: + srv = settings.get_oracle_server(srv_id) + srv_name = srv.name if srv else srv_id + keyboard.append([InlineKeyboardButton(srv_name, callback_data=f"select_server:{srv_id}")]) + except Exception: + # Fallback: use server IDs as labels + keyboard = [ + [InlineKeyboardButton(srv_id, callback_data=f"select_server:{srv_id}")] + for srv_id in servers + ] + keyboard.append([InlineKeyboardButton("Anulează", callback_data="cancel")]) + + await edit_login_message( + context=context, + chat_id=chat_id, + text="Email identificat pe mai multe servere.\n\nSelectează serverul pentru autentificare:", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + return AWAITING_SERVER_SELECTION + + # Single server or no cache hit — proceed directly + server_id = servers[0] if servers else None + context.user_data['server_id'] = server_id + + return await _send_email_code(context, chat_id, email, server_id, user_id) + + +# ============================================================================ +# STATE: AWAITING_SERVER_SELECTION +# ============================================================================ + +async def handle_server_selected(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Callback pentru selectarea serverului Oracle (mod multi-server)""" + query = update.callback_query + user_id = update.effective_user.id + chat_id = update.effective_chat.id + + await query.answer() + + # Extract server_id from callback data: "select_server:" + server_id = query.data.split(":", 1)[1] + + email = context.user_data.get('pending_email') + if not email: + await edit_login_message( + context=context, + chat_id=chat_id, + text="Sesiune expirată\n\nIncearcă din nou cu /login" + ) + return ConversationHandler.END + + # Save selected server to context + context.user_data['server_id'] = server_id + logger.info(f"[EMAIL-AUTH] User {user_id} selected server '{server_id}' for {email[:3]}***") + + # Clean up old pending codes then send code on selected server + await delete_user_email_codes(user_id) + + return await _send_email_code(context, chat_id, email, server_id, user_id) + + # ============================================================================ # STATE: AWAITING_CODE # ============================================================================ @@ -593,12 +676,14 @@ async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE): try: # Call backend endpoint pentru verificare parolă + JWT backend_client = get_backend_client() + server_id = context.user_data.get('server_id') response = await backend_client.login_with_email( email=email, password=password, telegram_user_id=user_id, - session_token=session_token + session_token=session_token, + server_id=server_id ) if not response.get('success'): @@ -749,6 +834,9 @@ email_login_handler = ConversationHandler( AWAITING_EMAIL: [ MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email) ], + AWAITING_SERVER_SELECTION: [ + CallbackQueryHandler(handle_server_selected, pattern='^select_server:') + ], AWAITING_CODE: [ MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code), CallbackQueryHandler(resend_code_callback, pattern='^resend:') diff --git a/backend/modules/telegram/bot/handlers.py b/backend/modules/telegram/bot/handlers.py index 81627eb..bcd279f 100644 --- a/backend/modules/telegram/bot/handlers.py +++ b/backend/modules/telegram/bot/handlers.py @@ -20,7 +20,7 @@ from backend.modules.telegram.auth.linking import ( get_user_companies ) from backend.modules.telegram.agent.session import get_session_manager -from backend.modules.telegram.db.operations import update_user_last_active +from backend.modules.telegram.db.operations import update_user_last_active, link_user_to_oracle from backend.modules.telegram.api.client import get_backend_client logger = logging.getLogger(__name__) @@ -1279,9 +1279,82 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE try: telegram_user = update.effective_user telegram_user_id = telegram_user.id - text = update.message.text.strip().upper() + text = update.message.text.strip() - logger.info(f"Text message from user {telegram_user_id}: {text}") + logger.info(f"Text message from user {telegram_user_id}") + + # Check if user is awaiting password for server switch + pending_server_id = context.user_data.get('pending_switch_server_id') + if pending_server_id: + # Șterge IMEDIAT mesajul cu parola (securitate) + try: + await update.message.delete() + except Exception as e: + logger.warning(f"Could not delete password message: {e}") + + oracle_password = text + jwt_token = context.user_data.pop('pending_switch_jwt_token', None) + username = context.user_data.pop('pending_switch_username', None) + context.user_data.pop('pending_switch_server_id', None) + + if not jwt_token or not username: + await update.effective_chat.send_message("Sesiune expirată. Încearcă din nou.") + return + + await update.effective_chat.send_message("Se verifică parola și se schimbă serverul...") + + client = get_backend_client() + async with client: + result = await client.switch_server( + jwt_token=jwt_token, + oracle_username=username, + new_server_id=pending_server_id, + oracle_password=oracle_password + ) + + if not result.get('success'): + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + await update.effective_chat.send_message( + f"❌ {result.get('message', 'Eroare la schimbarea serverului')}\n\nReîncearcă cu /menu → Schimbă server.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("« Meniu", callback_data="action:menu")]]) + ) + return + + # Salvează noul JWT în SQLite + from datetime import datetime, timedelta + token_expires_at = datetime.now() + timedelta(minutes=30) + await link_user_to_oracle( + telegram_user_id=telegram_user_id, + oracle_username=result.get('username', username), + jwt_token=result['access_token'], + jwt_refresh_token=result['refresh_token'], + token_expires_at=token_expires_at + ) + + # Curăță compania din sesiune — aparținea serverului vechi + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + session.clear_active_company() + await session_manager.save_session(telegram_user_id) + + try: + from backend.config import settings + srv = settings.get_oracle_server(pending_server_id) + srv_display = srv.name if srv else pending_server_id + except Exception: + srv_display = pending_server_id + + await update.effective_chat.send_message(f"✅ Server schimbat: **{srv_display}**\nSelectează firma...", parse_mode=ParseMode.MARKDOWN) + + await _handle_selectcompany_view( + query_or_update=update, + telegram_user_id=telegram_user_id, + jwt_token=result['access_token'], + is_callback=False, + page=0, + search_term="" + ) + return # Check if user is already linked is_linked = await check_user_linked(telegram_user_id) @@ -1291,6 +1364,8 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE # (could add natural language processing here in the future) return + text = text.upper() # Only uppercase for linking code check + # User is NOT linked - check if text looks like a linking code # Linking codes are exactly 8 alphanumeric characters if len(text) == 8 and text.isalnum(): @@ -1740,6 +1815,43 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) search_term="" ) + elif action == "switch_server": + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + from shared.auth.email_server_cache import email_server_cache + from backend.modules.telegram.bot.menus import pad_message_for_wide_buttons + + username = auth_data['username'] + + try: + servers = await email_server_cache.get_servers_for_username(username) + except Exception as e: + logger.error(f"Could not get servers for {username}: {e}") + await query.answer("Eroare la obținerea serverelor.", show_alert=True) + return + + if len(servers) <= 1: + await query.answer("Ești pe singurul server disponibil.", show_alert=True) + return + + # Build server selection keyboard + try: + from backend.config import settings + keyboard_rows = [] + for srv_id in servers: + srv = settings.get_oracle_server(srv_id) + srv_name = srv.name if srv else srv_id + keyboard_rows.append([InlineKeyboardButton(srv_name, callback_data=f"switch_server_confirm:{srv_id}")]) + except Exception: + keyboard_rows = [[InlineKeyboardButton(s, callback_data=f"switch_server_confirm:{s}")] for s in servers] + + keyboard_rows.append([InlineKeyboardButton("« Înapoi", callback_data="action:menu")]) + + await query.edit_message_text( + pad_message_for_wide_buttons(f"Selectează serverul Oracle:\n\nUtilizator: {username}"), + reply_markup=InlineKeyboardMarkup(keyboard_rows), + parse_mode=ParseMode.MARKDOWN + ) + async def handle_action_callback(query, telegram_user_id: int, callback_data: str): """ @@ -2099,6 +2211,241 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): parse_mode=ParseMode.MARKDOWN ) + elif callback_data == "select_company_alpha_menu": + # Show A-Z letter filter keyboard + from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard + await query.edit_message_text( + "**Selectează litera**\n\nAlege prima literă a firmei:", + reply_markup=create_alpha_filter_keyboard(), + parse_mode=ParseMode.MARKDOWN + ) + + elif callback_data.startswith("select_company_alpha:"): + # Filter companies by starting letter and show page 0 + letter = callback_data.split(":", 1)[1] # "A" or "ALL" + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + client = get_backend_client() + async with client: + all_companies = await client.get_user_companies(jwt_token=jwt_token) + + if letter == "ALL": + filtered = all_companies + else: + filtered = [ + c for c in all_companies + if c.get('name', c.get('nume_firma', '')).upper().startswith(letter) + ] + + if not filtered: + await query.answer(f"Nicio firmă cu litera {letter}.", show_alert=True) + return + + from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated + keyboard = create_company_selection_keyboard_paginated( + filtered, page=0, + back_callback="select_company_alpha_menu", + page_callback_prefix="select_company_alpha_page", + page_callback_suffix=f":{letter}" + ) + label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele" + await query.edit_message_text( + f"**{label}** ({len(filtered)}):", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + + elif callback_data.startswith("select_company_alpha_page:"): + # Paginate within an alpha-filtered company list + # Callback format: select_company_alpha_page:PAGE:LETTER + parts = callback_data.split(":") + page = int(parts[1]) + letter = parts[2] # "A"–"Z" or "ALL" + + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + client = get_backend_client() + async with client: + all_companies = await client.get_user_companies(jwt_token=jwt_token) + + if letter == "ALL": + filtered = all_companies + else: + filtered = [ + c for c in all_companies + if c.get('name', c.get('nume_firma', '')).upper().startswith(letter) + ] + + from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated + keyboard = create_company_selection_keyboard_paginated( + filtered, page=page, + back_callback="select_company_alpha_menu", + page_callback_prefix="select_company_alpha_page", + page_callback_suffix=f":{letter}" + ) + label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele" + await query.edit_message_text( + f"**{label}** ({len(filtered)}):", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + + elif callback_data == "clients_alpha_menu": + # Show A-Z letter filter keyboard for clients + from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner + await query.edit_message_text( + "**Selecteaza litera**\n\nAlege prima litera a clientului:", + reply_markup=create_alpha_filter_keyboard_partner("clients"), + parse_mode=ParseMode.MARKDOWN + ) + + elif callback_data.startswith("clients_alpha:"): + # Filter clients by starting letter and show page 0 + letter = callback_data.split(":", 1)[1] # "A" or "ALL" + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + company = session.get_active_company() + + from backend.modules.telegram.bot.helpers import get_clients_with_maturity + clients_data = await get_clients_with_maturity(company['id'], jwt_token) + all_clients = clients_data['clients'] + + if letter == "ALL": + filtered = all_clients + else: + filtered = [ + c for c in all_clients + if c.get('name', '').upper().startswith(letter) + ] + + if not filtered: + await query.answer(f"Niciun client cu litera {letter}.", show_alert=True) + return + + from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company + from backend.modules.telegram.bot.formatters import format_clients_balance_response + content = format_clients_balance_response(filtered, clients_data['maturity']) + label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii" + response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name']) + keyboard = create_client_list_keyboard(filtered, page=0, letter=letter) + await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif callback_data.startswith("clients_alpha_page:"): + # Paginate within an alpha-filtered client list + # Callback format: clients_alpha_page:PAGE:LETTER + parts = callback_data.split(":") + page = int(parts[1]) + letter = parts[2] # "A"–"Z" or "ALL" + + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + company = session.get_active_company() + + from backend.modules.telegram.bot.helpers import get_clients_with_maturity + clients_data = await get_clients_with_maturity(company['id'], jwt_token) + all_clients = clients_data['clients'] + + if letter == "ALL": + filtered = all_clients + else: + filtered = [ + c for c in all_clients + if c.get('name', '').upper().startswith(letter) + ] + + from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company + from backend.modules.telegram.bot.formatters import format_clients_balance_response + content = format_clients_balance_response(filtered, clients_data['maturity']) + label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii" + response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name']) + keyboard = create_client_list_keyboard(filtered, page=page, letter=letter) + await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif callback_data == "suppliers_alpha_menu": + # Show A-Z letter filter keyboard for suppliers + from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner + await query.edit_message_text( + "**Selecteaza litera**\n\nAlege prima litera a furnizorului:", + reply_markup=create_alpha_filter_keyboard_partner("suppliers"), + parse_mode=ParseMode.MARKDOWN + ) + + elif callback_data.startswith("suppliers_alpha:"): + # Filter suppliers by starting letter and show page 0 + letter = callback_data.split(":", 1)[1] # "A" or "ALL" + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + company = session.get_active_company() + + from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity + suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token) + all_suppliers = suppliers_data['suppliers'] + + if letter == "ALL": + filtered = all_suppliers + else: + filtered = [ + s for s in all_suppliers + if s.get('name', '').upper().startswith(letter) + ] + + if not filtered: + await query.answer(f"Niciun furnizor cu litera {letter}.", show_alert=True) + return + + from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company + from backend.modules.telegram.bot.formatters import format_suppliers_balance_response + content = format_suppliers_balance_response(filtered, suppliers_data['maturity']) + label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii" + response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name']) + keyboard = create_supplier_list_keyboard(filtered, page=0, letter=letter) + await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif callback_data.startswith("suppliers_alpha_page:"): + # Paginate within an alpha-filtered supplier list + # Callback format: suppliers_alpha_page:PAGE:LETTER + parts = callback_data.split(":") + page = int(parts[1]) + letter = parts[2] # "A"–"Z" or "ALL" + + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + company = session.get_active_company() + + from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity + suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token) + all_suppliers = suppliers_data['suppliers'] + + if letter == "ALL": + filtered = all_suppliers + else: + filtered = [ + s for s in all_suppliers + if s.get('name', '').upper().startswith(letter) + ] + + from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company + from backend.modules.telegram.bot.formatters import format_suppliers_balance_response + content = format_suppliers_balance_response(filtered, suppliers_data['maturity']) + label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii" + response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name']) + keyboard = create_supplier_list_keyboard(filtered, page=page, letter=letter) + await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + elif callback_data.startswith("select_company:"): # Handle company selection company_id = int(callback_data.split(":")[1]) @@ -2151,6 +2498,44 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): "Companie negasita sau nu ai acces la ea." ) + # ========== SWITCH SERVER CALLBACKS ========== + + elif callback_data.startswith("switch_server_confirm:"): + new_server_id = callback_data.split(":", 1)[1] + telegram_user_id = update.effective_user.id + + auth_data = await get_user_auth_data(telegram_user_id) + if not auth_data: + await query.edit_message_text("Sesiune expirată. Re-autentifică-te cu /login") + return + + # Stochează serverul țintă și cere parola — servere diferite pot avea parole diferite + context.user_data['pending_switch_server_id'] = new_server_id + context.user_data['pending_switch_jwt_token'] = auth_data['jwt_token'] + context.user_data['pending_switch_username'] = auth_data['username'] + + try: + from backend.config import settings + srv = settings.get_oracle_server(new_server_id) + srv_display = srv.name if srv else new_server_id + except Exception: + srv_display = new_server_id + + from telegram import ForceReply + await query.edit_message_text( + f"🔐 **Schimbare server: {srv_display}**\n\n" + f"Introdu parola Oracle pentru acest server:\n" + f"_(Mesajul cu parola va fi șters automat)_", + parse_mode=ParseMode.MARKDOWN + ) + # Trimite un mesaj separat cu ForceReply pentru a forța input-ul + await context.bot.send_message( + chat_id=update.effective_chat.id, + text="Parolă:", + reply_markup=ForceReply(selective=True, input_field_placeholder="Parola Oracle...") + ) + return + # ========== LOGOUT CALLBACKS ========== elif callback_data == "logout_confirm": @@ -2741,6 +3126,40 @@ async def _handle_selectcompany_view( ) return + # Auto-selectează dacă există exact o singură firmă + if len(companies) == 1: + selected = companies[0] + company_id = selected.get('id_firma', selected.get('id')) + company_name = selected.get('name', selected.get('nume_firma', 'N/A')) + company_cui = selected.get('fiscal_code', selected.get('cui')) + + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + session.set_active_company( + company_id=company_id, + company_name=company_name, + company_cui=company_cui + ) + await session_manager.save_session(telegram_user_id) + + from backend.modules.telegram.bot.menus import create_main_menu, get_menu_message + keyboard = create_main_menu(company_name=company_name, company_cui=company_cui) + menu_text = f"✅ Firmă selectată automat: **{company_name}**\n\n" + get_menu_message(company_name, company_cui) + + if is_callback: + await query_or_update.edit_message_text( + menu_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + else: + await query_or_update.message.reply_text( + menu_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + return + from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated keyboard = create_company_selection_keyboard_paginated(companies, page=page) diff --git a/backend/modules/telegram/bot/helpers.py b/backend/modules/telegram/bot/helpers.py index e52196a..710186a 100644 --- a/backend/modules/telegram/bot/helpers.py +++ b/backend/modules/telegram/bot/helpers.py @@ -169,7 +169,10 @@ def create_company_selection_keyboard( def create_company_selection_keyboard_paginated( companies: List[Dict[str, Any]], page: int = 0, - per_page: int = 10 + per_page: int = 20, + back_callback: str = "action:menu", + page_callback_prefix: str = "select_company_page", + page_callback_suffix: str = "" ) -> InlineKeyboardMarkup: """ Create paginated inline keyboard for company selection. @@ -180,7 +183,10 @@ def create_company_selection_keyboard_paginated( Args: companies: Full list of company dicts (with id, nume_firma, cui) page: Current page number (0-indexed) - per_page: Number of companies per page (default: 10) + per_page: Number of companies per page (default: 20) + back_callback: Callback data for the back button (default: "action:menu") + page_callback_prefix: Prefix for pagination callbacks (default: "select_company_page") + page_callback_suffix: Suffix appended after page number in pagination callbacks Returns: InlineKeyboardMarkup with company buttons and pagination controls @@ -221,8 +227,9 @@ def create_company_selection_keyboard_paginated( # Previous button if page > 0: + prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}" nav_buttons.append( - InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}") + InlineKeyboardButton("< Anterior", callback_data=prev_cb) ) # Page indicator (non-clickable) @@ -232,20 +239,76 @@ def create_company_selection_keyboard_paginated( # Next button if page < total_pages - 1: + next_cb = f"{page_callback_prefix}:{page+1}{page_callback_suffix}" nav_buttons.append( - InlineKeyboardButton("Urmator >", callback_data=f"select_company_page:{page+1}") + InlineKeyboardButton("Urmator >", callback_data=next_cb) ) keyboard.append(nav_buttons) - # Back to menu button + # A-Z filter + back button keyboard.append([ - InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu") + InlineKeyboardButton("Filtrare A-Z", callback_data="select_company_alpha_menu") + ]) + keyboard.append([ + InlineKeyboardButton("« Înapoi", callback_data=back_callback) ]) return InlineKeyboardMarkup(keyboard) +def create_alpha_filter_keyboard() -> InlineKeyboardMarkup: + """ + Create inline keyboard with A–Z letter buttons for filtering companies. + + Displays 26 letter buttons in rows of 6, plus a 'Toată lista' button + that shows all companies without filtering. + + Returns: + InlineKeyboardMarkup with letter buttons and navigation + """ + letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + keyboard = [] + row_size = 6 + for i in range(0, len(letters), row_size): + row = [ + InlineKeyboardButton(l, callback_data=f"select_company_alpha:{l}") + for l in letters[i:i + row_size] + ] + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("Toată lista", callback_data="select_company_alpha:ALL"), + InlineKeyboardButton("« Meniu", callback_data="action:menu") + ]) + return InlineKeyboardMarkup(keyboard) + + +def create_alpha_filter_keyboard_partner(partner_type: str) -> InlineKeyboardMarkup: + """ + Create inline keyboard with A–Z letter buttons for filtering clients or suppliers. + + Args: + partner_type: "clients" or "suppliers" + + Returns: + InlineKeyboardMarkup with letter buttons and navigation + """ + letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + keyboard = [] + row_size = 6 + for i in range(0, len(letters), row_size): + row = [ + InlineKeyboardButton(l, callback_data=f"{partner_type}_alpha:{l}") + for l in letters[i:i + row_size] + ] + keyboard.append(row) + keyboard.append([ + InlineKeyboardButton("Toata lista", callback_data=f"{partner_type}_alpha:ALL"), + InlineKeyboardButton("« Meniu", callback_data="action:menu") + ]) + return InlineKeyboardMarkup(keyboard) + + def format_company_context_footer(company_name: str) -> str: """ Format discrete footer with company context. diff --git a/backend/modules/telegram/bot/menus.py b/backend/modules/telegram/bot/menus.py index 988c4be..9e9122f 100644 --- a/backend/modules/telegram/bot/menus.py +++ b/backend/modules/telegram/bot/menus.py @@ -260,7 +260,13 @@ def create_main_menu( InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache") ]) - # Row 6: Help/Logout buttons (authenticated) or Login button (non-authenticated) + # Row 6: Switch Server button (authenticated only) + if is_authenticated: + keyboard.append([ + InlineKeyboardButton("Schimba Server", callback_data="menu:switch_server"), + ]) + + # Row 7: Help/Logout buttons (authenticated) or Login button (non-authenticated) if is_authenticated: keyboard.append([ InlineKeyboardButton("Help", callback_data="action:help"), @@ -334,7 +340,7 @@ def create_action_buttons(current_view: str, show_export: bool = True, show_back return InlineKeyboardMarkup(keyboard) -def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup: +def create_client_list_keyboard(clients: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup: """ Create client list keyboard (Level 2) with client buttons and pagination. @@ -344,6 +350,7 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: clients: List of client dicts with keys: id, name, balance max_items: Maximum number of clients per page (default: 10) page: Current page number (0-indexed) + letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination Returns: InlineKeyboardMarkup with client list buttons and pagination @@ -387,10 +394,18 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: if total_pages > 1: nav_buttons = [] + # Choose pagination callback based on whether letter filter is active + if letter: + prev_cb = f"clients_alpha_page:{page-1}:{letter}" + next_cb = f"clients_alpha_page:{page+1}:{letter}" + else: + prev_cb = f"clients_page:{page-1}" + next_cb = f"clients_page:{page+1}" + # Previous button if page > 0: nav_buttons.append( - InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}") + InlineKeyboardButton("< Anterior", callback_data=prev_cb) ) # Page indicator (non-clickable) @@ -401,20 +416,26 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: # Next button if page < total_pages - 1: nav_buttons.append( - InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}") + InlineKeyboardButton("Urmator >", callback_data=next_cb) ) keyboard.append(nav_buttons) - # Navigation row: Back button only + # Filtrare A-Z button keyboard.append([ - InlineKeyboardButton("< Înapoi", callback_data="action:menu") + InlineKeyboardButton("Filtrare A-Z", callback_data="clients_alpha_menu") + ]) + + # Back button: to A-Z menu if filtering, otherwise to main menu + back_callback = "clients_alpha_menu" if letter else "action:menu" + keyboard.append([ + InlineKeyboardButton("< Inapoi", callback_data=back_callback) ]) return InlineKeyboardMarkup(keyboard) -def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup: +def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup: """ Create supplier list keyboard (Level 2) with supplier buttons and pagination. @@ -424,6 +445,7 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa suppliers: List of supplier dicts with keys: id, name, balance max_items: Maximum number of suppliers per page (default: 10) page: Current page number (0-indexed) + letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination Returns: InlineKeyboardMarkup with supplier list buttons and pagination @@ -467,10 +489,18 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa if total_pages > 1: nav_buttons = [] + # Choose pagination callback based on whether letter filter is active + if letter: + prev_cb = f"suppliers_alpha_page:{page-1}:{letter}" + next_cb = f"suppliers_alpha_page:{page+1}:{letter}" + else: + prev_cb = f"suppliers_page:{page-1}" + next_cb = f"suppliers_page:{page+1}" + # Previous button if page > 0: nav_buttons.append( - InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}") + InlineKeyboardButton("< Anterior", callback_data=prev_cb) ) # Page indicator (non-clickable) @@ -481,14 +511,20 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa # Next button if page < total_pages - 1: nav_buttons.append( - InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}") + InlineKeyboardButton("Urmator >", callback_data=next_cb) ) keyboard.append(nav_buttons) - # Navigation row: Back button only + # Filtrare A-Z button keyboard.append([ - InlineKeyboardButton("< Înapoi", callback_data="action:menu") + InlineKeyboardButton("Filtrare A-Z", callback_data="suppliers_alpha_menu") + ]) + + # Back button: to A-Z menu if filtering, otherwise to main menu + back_callback = "suppliers_alpha_menu" if letter else "action:menu" + keyboard.append([ + InlineKeyboardButton("< Inapoi", callback_data=back_callback) ]) return InlineKeyboardMarkup(keyboard) diff --git a/backend/modules/telegram/db/database.py b/backend/modules/telegram/db/database.py index fe96bc1..9fd7a35 100644 --- a/backend/modules/telegram/db/database.py +++ b/backend/modules/telegram/db/database.py @@ -148,6 +148,14 @@ async def init_database() -> None: ON email_auth_codes(expires_at) """) + # Migration: add server_id column to telegram_auth_codes if missing + try: + await db.execute("ALTER TABLE telegram_auth_codes ADD COLUMN server_id TEXT") + await db.commit() + logger.info("Migration: added server_id column to telegram_auth_codes") + except Exception: + pass # Column already exists + await db.commit() logger.info("Database initialized successfully") diff --git a/backend/modules/telegram/db/operations.py b/backend/modules/telegram/db/operations.py index 1230b77..5569162 100644 --- a/backend/modules/telegram/db/operations.py +++ b/backend/modules/telegram/db/operations.py @@ -288,7 +288,8 @@ async def create_auth_code( code: str, telegram_user_id: int, oracle_username: str, - expires_in_minutes: int = 5 + expires_in_minutes: int = 5, + server_id: Optional[str] = None ) -> bool: """ Create a new authentication code for linking. @@ -298,6 +299,7 @@ async def create_auth_code( telegram_user_id: Telegram user ID oracle_username: Oracle username to link expires_in_minutes: Code expiration time in minutes (default: 5) + server_id: Oracle server ID (for multi-server mode) Returns: bool: True if successful @@ -310,13 +312,13 @@ async def create_auth_code( db.row_factory = aiosqlite.Row await db.execute(""" INSERT INTO telegram_auth_codes ( - code, telegram_user_id, oracle_username, expires_at + code, telegram_user_id, oracle_username, expires_at, server_id ) - VALUES (?, ?, ?, ?) - """, (code, telegram_user_id, oracle_username, expires_at)) + VALUES (?, ?, ?, ?, ?) + """, (code, telegram_user_id, oracle_username, expires_at, server_id)) await db.commit() - logger.info(f"Auth code created for user {telegram_user_id}") + logger.info(f"Auth code created for user {telegram_user_id} (server_id={server_id})") return True except Exception as e: diff --git a/backend/modules/telegram/routers/auth_codes.py b/backend/modules/telegram/routers/auth_codes.py index 50d2387..fdee6cb 100644 --- a/backend/modules/telegram/routers/auth_codes.py +++ b/backend/modules/telegram/routers/auth_codes.py @@ -2,7 +2,7 @@ API Router pentru Telegram Bot Integration Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot """ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request from typing import List, Optional, Dict, Any # import sys # Removed - no longer needed import os @@ -57,6 +57,7 @@ class VerifyUserRequest(BaseModel): oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)") username: Optional[str] = Field(default=None, description="Username pentru verificare completă") password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă") + server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)") class VerifyUserResponse(BaseModel): @@ -100,6 +101,7 @@ class ExportReportResponse(BaseModel): class VerifyEmailRequest(BaseModel): """Request pentru verificarea email-ului în Oracle""" email: str = Field(description="Adresa de email Oracle") + server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)") class VerifyEmailResponse(BaseModel): @@ -115,6 +117,7 @@ class TelegramEmailLoginRequest(BaseModel): password: str = Field(description="Parola Oracle") telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram") session_token: str = Field(description="Token de sesiune pentru preveni spoofing") + server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)") class TelegramEmailLoginResponse(BaseModel): @@ -129,6 +132,21 @@ class TelegramEmailLoginResponse(BaseModel): message: str = Field(description="Mesaj de status") +class SwitchServerRequest(BaseModel): + """Request pentru schimbarea serverului Oracle""" + oracle_username: str = Field(description="Username Oracle al utilizatorului curent") + new_server_id: str = Field(description="ID-ul noului server Oracle") + oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)") + + +class SwitchServerResponse(BaseModel): + """Response pentru schimbarea serverului Oracle""" + success: bool = Field(description="True dacă schimbarea a reușit") + access_token: Optional[str] = Field(default=None, description="Noul JWT access token") + refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token") + message: str = Field(description="Mesaj de status") + + # ==================== Helper Functions ==================== # Rate limiting storage (in-memory) @@ -212,7 +230,7 @@ def generate_linking_code(length: int = 8) -> str: return ''.join(secrets.choice(alphabet) for _ in range(length)) -async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]: +async def get_oracle_user_by_username(username: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]: """ Obține informații despre utilizator din Oracle FĂRĂ verificare parolă. @@ -221,12 +239,13 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]] Args: username: Username-ul utilizatorului Oracle + server_id: ID-ul serverului Oracle (pentru multi-server mode) Returns: Dict cu informații despre utilizator sau None dacă nu există """ try: - async with oracle_pool.get_connection() as connection: + async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: # Obține detalii utilizator cursor.execute(""" @@ -270,19 +289,20 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]] return None -async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]: +async def verify_oracle_user(username: str, password: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]: """ Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator Args: username: Username-ul utilizatorului password: Parola utilizatorului + server_id: ID-ul serverului Oracle (pentru multi-server mode) Returns: Dict cu informații despre utilizator sau None dacă verificarea eșuează """ try: - async with oracle_pool.get_connection() as connection: + async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: # Verifică autentificarea cursor.execute(""" @@ -344,6 +364,7 @@ async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, @router.post("/auth/generate-code", response_model=GenerateCodeResponse) async def generate_linking_code_endpoint( + request: Request, current_user: CurrentUser = Depends(get_current_user) ): """ @@ -360,8 +381,12 @@ async def generate_linking_code_endpoint( - Codul expiră după 15 minute - Fiecare request generează un cod nou (codurile vechi devin invalide) - Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram) + - server_id este extras automat din JWT (setat de auth middleware în request.state) """ try: + # Extrage server_id din JWT (setat de auth middleware) + server_id = getattr(request.state, 'server_id', None) + # Generează cod unic linking_code = generate_linking_code() @@ -378,7 +403,8 @@ async def generate_linking_code_endpoint( "code": linking_code, "telegram_user_id": 0, # Not known yet (user hasn't linked) "oracle_username": current_user.username, - "expires_in_minutes": expires_in_minutes + "expires_in_minutes": expires_in_minutes, + "server_id": server_id } ) @@ -442,7 +468,7 @@ async def verify_user_endpoint(request: VerifyUserRequest): try: # Flow A: Auto-linking (oracle_username provided, no password) if request.oracle_username and not request.password: - user_data = await get_oracle_user_by_username(request.oracle_username) + user_data = await get_oracle_user_by_username(request.oracle_username, server_id=request.server_id) if not user_data: return VerifyUserResponse( @@ -452,7 +478,7 @@ async def verify_user_endpoint(request: VerifyUserRequest): # Flow B: Full verification (username + password provided) elif request.username and request.password: - user_data = await verify_oracle_user(request.username, request.password) + user_data = await verify_oracle_user(request.username, request.password, server_id=request.server_id) if not user_data: return VerifyUserResponse( @@ -467,17 +493,19 @@ async def verify_user_endpoint(request: VerifyUserRequest): message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)" ) - # Generează JWT tokens + # Generează JWT tokens (cu server_id pentru multi-server mode) access_token = jwt_handler.create_access_token( username=user_data['username'], companies=user_data['companies'], user_id=user_data['user_id'], - permissions=user_data['permissions'] + permissions=user_data['permissions'], + server_id=request.server_id ) refresh_token = jwt_handler.create_refresh_token( username=user_data['username'], - user_id=user_data['user_id'] + user_id=user_data['user_id'], + server_id=request.server_id ) return VerifyUserResponse( @@ -528,8 +556,8 @@ async def refresh_token_endpoint(request: RefreshTokenRequest): detail="Refresh token invalid sau expirat" ) - # Obține companiile actualizate din Oracle - async with oracle_pool.get_connection() as connection: + # Obține companiile actualizate din Oracle (folosind server_id din refresh token) + async with oracle_pool.get_connection(token_data.server_id) as connection: with connection.cursor() as cursor: cursor.execute(""" SELECT A.ID_FIRMA @@ -546,12 +574,13 @@ async def refresh_token_endpoint(request: RefreshTokenRequest): companies_result = cursor.fetchall() companies = [str(row[0]) for row in companies_result] - # Generează nou access token + # Generează nou access token (păstrează server_id din refresh token) new_access_token = jwt_handler.create_access_token( username=token_data.username, companies=companies, user_id=token_data.user_id, - permissions=token_data.permissions + permissions=token_data.permissions, + server_id=token_data.server_id ) return RefreshTokenResponse( @@ -580,7 +609,7 @@ async def verify_email_endpoint(request: VerifyEmailRequest): Security: Generic error messages to prevent email enumeration. """ try: - async with oracle_pool.get_connection() as connection: + async with oracle_pool.get_connection(request.server_id) as connection: with connection.cursor() as cursor: # Query to find username by email cursor.execute(""" @@ -649,7 +678,7 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest): ) try: - async with oracle_pool.get_connection() as connection: + async with oracle_pool.get_connection(request.server_id) as connection: with connection.cursor() as cursor: # 3. Find username by email cursor.execute(""" @@ -719,18 +748,18 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest): # 6. Get user permissions (default for Telegram) permissions = ['read', 'reports'] - # 7. Generate JWT tokens - token_data = { - "username": username, - "user_id": user_id, - "companies": company_ids, - "permissions": permissions - } - - access_token = jwt_handler.create_access_token(**token_data) + # 7. Generate JWT tokens (with server_id for multi-server routing) + access_token = jwt_handler.create_access_token( + username=username, + user_id=user_id, + companies=company_ids, + permissions=permissions, + server_id=request.server_id + ) refresh_token = jwt_handler.create_refresh_token( username=username, - user_id=user_id + user_id=user_id, + server_id=request.server_id ) return TelegramEmailLoginResponse( @@ -754,6 +783,82 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest): ) +@router.post("/auth/switch-server", response_model=SwitchServerResponse) +async def switch_server_endpoint( + request: SwitchServerRequest, + current_user: CurrentUser = Depends(get_current_user) +): + """ + Schimbă serverul Oracle activ fără re-autentificare. + + Flux: + 1. Verifică că oracle_username din request == username din JWT (anti-impersonare) + 2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username) + 3. Generează JWT nou cu noul server_id + 4. Returnează tokenurile noi + + Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)). + """ + # Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont + if request.oracle_username.upper() != current_user.username.upper(): + raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit") + + # Verifică că utilizatorul există pe noul server + user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id) + if not user_data: + return SwitchServerResponse( + success=False, + message=f"Utilizatorul nu există pe serverul {request.new_server_id}" + ) + + # Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT + if request.oracle_password: + try: + async with oracle_pool.get_connection(request.new_server_id) as connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT pack_drepturi.verificautilizator(:username, :password) + FROM DUAL + """, { + "username": request.oracle_username.upper(), + "password": request.oracle_password + }) + verification_result = cursor.fetchone()[0] + + if verification_result == -1: + return SwitchServerResponse( + success=False, + message="Parolă incorectă pentru acest server" + ) + except Exception as e: + logger.error(f"Password verification error during server switch: {e}") + return SwitchServerResponse( + success=False, + message="Eroare la verificarea parolei pe noul server" + ) + + # Generează JWT nou cu noul server_id + access_token = jwt_handler.create_access_token( + username=user_data['username'], + companies=user_data['companies'], + user_id=user_data['user_id'], + permissions=user_data['permissions'], + server_id=request.new_server_id + ) + refresh_token = jwt_handler.create_refresh_token( + username=user_data['username'], + user_id=user_data['user_id'], + server_id=request.new_server_id + ) + + return SwitchServerResponse( + success=True, + access_token=access_token, + refresh_token=refresh_token, + message="Server schimbat cu succes" + ) + + @router.post("/export", response_model=ExportReportResponse) async def export_report_endpoint( request: ExportReportRequest, diff --git a/backend/modules/telegram/routers/internal_api.py b/backend/modules/telegram/routers/internal_api.py index c67c5c7..4c7c31e 100644 --- a/backend/modules/telegram/routers/internal_api.py +++ b/backend/modules/telegram/routers/internal_api.py @@ -56,6 +56,10 @@ class SaveAuthCodeRequest(BaseModel): ge=1, le=60 ) + server_id: Optional[str] = Field( + default=None, + description="Oracle server ID (for multi-server mode)" + ) class SaveAuthCodeResponse(BaseModel): @@ -151,7 +155,8 @@ async def save_auth_code(request: SaveAuthCodeRequest): code=request.code, telegram_user_id=request.telegram_user_id, oracle_username=request.oracle_username, - expires_in_minutes=request.expires_in_minutes + expires_in_minutes=request.expires_in_minutes, + server_id=request.server_id ) if not success: diff --git a/backend/modules/telegram/utils/email_service.py b/backend/modules/telegram/utils/email_service.py index 126a2be..7d456f4 100644 --- a/backend/modules/telegram/utils/email_service.py +++ b/backend/modules/telegram/utils/email_service.py @@ -2,8 +2,7 @@ Async SMTP Email Service with retry logic and proper error handling """ import aiosmtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart +from email.message import EmailMessage import os import logging from typing import Optional @@ -52,32 +51,33 @@ class EmailService: Raises: No exceptions - returns False on all failures """ - subject = "Codul tău de autentificare ROA2WEB" - html_body = self._create_email_template(code, username) + subject = "Autentificare ROA2WEB" + text_body = self._create_email_template(code, username) for attempt in range(1, self.max_retries + 1): try: - await self._send_email(to_email, subject, html_body) + await self._send_email(to_email, subject, text_body) logger.info( - f"Email sent successfully to {to_email} " - f"(attempt {attempt}/{self.max_retries})" + f"[EMAIL] ✅ Sent auth code to {to_email} " + f"(attempt {attempt}/{self.max_retries}) via {self.smtp_host}:{self.smtp_port}" ) return True except aiosmtplib.SMTPException as e: - logger.warning( - f"SMTP error on attempt {attempt}/{self.max_retries}: {e}" + logger.error( + f"[EMAIL] ❌ Attempt {attempt}/{self.max_retries} failed for {to_email}: " + f"{type(e).__name__}: {e}" ) if attempt < self.max_retries: # Exponential backoff: 2s, 4s, 8s delay = self.retry_delay * (2 ** (attempt - 1)) - logger.info(f"Retrying in {delay}s...") + logger.info(f"[EMAIL] Retrying in {delay}s...") await asyncio.sleep(delay) else: - logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts") + logger.error(f"[EMAIL] ❌ All {self.max_retries} attempts failed for {to_email}") except Exception as e: - logger.error(f"Unexpected error sending email: {e}", exc_info=True) + logger.error(f"[EMAIL] ❌ Unexpected error on attempt {attempt}/{self.max_retries} for {to_email}: {type(e).__name__}: {e}", exc_info=True) return False return False @@ -86,29 +86,24 @@ class EmailService: self, to_email: str, subject: str, - html_body: str + text_body: str ) -> None: """ - Internal async SMTP sender + Internal async SMTP sender (plain text to avoid spam filters) Raises: aiosmtplib.SMTPException: On SMTP errors """ - message = MIMEMultipart("alternative") + message = EmailMessage() message["From"] = f"{self.from_name} <{self.from_email}>" message["To"] = to_email message["Subject"] = subject + message.set_content(text_body) - # Attach HTML body - html_part = MIMEText(html_body, "html", "utf-8") - message.attach(html_part) - - # Send via async SMTP with STARTTLS - # Using start_tls parameter for automatic STARTTLS handling smtp = aiosmtplib.SMTP( hostname=self.smtp_host, port=self.smtp_port, - start_tls=self.use_tls, # Use start_tls instead of use_tls + start_tls=self.use_tls, timeout=30 ) @@ -123,132 +118,15 @@ class EmailService: pass def _create_email_template(self, code: str, username: str) -> str: - """Generate HTML email template""" - return f""" - - - - - - - - -
-
-

ROA2WEB

-

Autentificare Telegram Bot

-
- -
-

Salut {username},

- -

Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.

- -
-

- Codul tău de autentificare: -

- {code} -

- Introdu acest cod în conversația Telegram -

-
- -
- Important: Acest cod expiră în 5 minute - și poate fi folosit o singură dată. -
- -

După introducerea codului, vei fi solicitat să introduci parola ta Oracle.

- -
- -

- Nu ai solicitat acest cod?
- Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță. - Nimeni nu va avea acces la contul tău fără parola ta Oracle. -

-
- - -
- - - """ + """Generate plain text email body (HTML blocked by spam filters)""" + return ( + f"Codul tau de autentificare ROA2WEB:\n\n" + f" {code}\n\n" + f"Introdu acest cod in Telegram. Expira in 5 minute.\n\n" + f"---\n" + f"Solicitat pentru: {username}\n" + f"Daca nu ai initiat aceasta autentificare, ignora acest email." + ) # Singleton instance diff --git a/backend/run-with-restart.sh b/backend/run-with-restart.sh index 62582fb..ac7223f 100755 --- a/backend/run-with-restart.sh +++ b/backend/run-with-restart.sh @@ -11,6 +11,18 @@ LOG_FILE=${2:-/tmp/unified_backend.log} MAX_RESTARTS=10 RESTART_COUNT=0 RESTART_DELAY=3 +UVICORN_PID="" + +# On SIGTERM or SIGINT: kill uvicorn child and exit cleanly (no restart) +cleanup_and_exit() { + echo "[Backend Runner] Received termination signal, stopping..." | tee -a "$LOG_FILE" + if [ -n "$UVICORN_PID" ] && kill -0 "$UVICORN_PID" 2>/dev/null; then + kill "$UVICORN_PID" 2>/dev/null + wait "$UVICORN_PID" 2>/dev/null + fi + exit 0 +} +trap cleanup_and_exit SIGTERM SIGINT echo "[Backend Runner] Starting uvicorn with auto-restart (max $MAX_RESTARTS restarts)" | tee -a "$LOG_FILE" echo "[Backend Runner] Port: $PORT, Log: $LOG_FILE" | tee -a "$LOG_FILE" @@ -20,15 +32,27 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do echo "" | tee -a "$LOG_FILE" echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - Starting uvicorn (attempt $((RESTART_COUNT + 1))/$MAX_RESTARTS)..." | tee -a "$LOG_FILE" - # Run uvicorn - it will exit on crash (OOM, etc.) - uvicorn main:app --host 0.0.0.0 --port "$PORT" 2>&1 | tee -a "$LOG_FILE" + # Run uvicorn in background so we can track its PID and trap signals properly + uvicorn main:app --host 0.0.0.0 --port "$PORT" >> "$LOG_FILE" 2>&1 & + UVICORN_PID=$! + + # Wait for uvicorn to exit + wait "$UVICORN_PID" EXIT_CODE=$? + UVICORN_PID="" END_TIME=$(date +%s) RUNTIME=$((END_TIME - START_TIME)) echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - uvicorn exited with code $EXIT_CODE after ${RUNTIME}s" | tee -a "$LOG_FILE" + # Exit if trap already fired (cleanup_and_exit sets UVICORN_PID="") + # or if uvicorn exited due to a signal (130=SIGINT, 143=SIGTERM, 137=SIGKILL) + if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ] || [ $EXIT_CODE -eq 137 ]; then + echo "[Backend Runner] Received termination signal (code $EXIT_CODE), exiting..." | tee -a "$LOG_FILE" + exit 0 + fi + # If it ran for more than 60 seconds, reset restart counter (was stable) if [ $RUNTIME -gt 60 ]; then RESTART_COUNT=0 @@ -38,12 +62,6 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do echo "[Backend Runner] Quick crash detected, restart count: $RESTART_COUNT/$MAX_RESTARTS" | tee -a "$LOG_FILE" fi - # Exit if user sent SIGINT (Ctrl+C) or SIGTERM - if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ]; then - echo "[Backend Runner] Received termination signal, exiting..." | tee -a "$LOG_FILE" - exit 0 - fi - # Wait before restart if [ $RESTART_COUNT -lt $MAX_RESTARTS ]; then echo "[Backend Runner] Restarting in ${RESTART_DELAY}s..." | tee -a "$LOG_FILE" diff --git a/deployment/windows/config/web.config b/deployment/windows/config/web.config index 863c72c..6e3f262 100644 --- a/deployment/windows/config/web.config +++ b/deployment/windows/config/web.config @@ -46,8 +46,8 @@ - - + + @@ -132,6 +132,18 @@ + + + + + + + + + + + + @@ -142,6 +154,12 @@ + + + + + + diff --git a/index.html b/index.html index 7dccfe2..c2e9092 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,9 @@ - + + @@ -27,8 +28,25 @@