Compare commits

...

5 Commits

Author SHA1 Message Date
Elisiário Couto
31382cab7a Fix notification channels returning null by connecting API to real implementations
- Update notification service to handle both old (api-key/chat-id) and new (token/chat_id) config formats
- Connect API service to actual Discord/Telegram notification implementations from CLI codebase
- Fix API routes to properly detect configured services using correct config keys
- Telegram notifications now work correctly, Discord properly shows as not configured
2025-09-09 16:10:08 +01:00
Elisiário Couto
223fd06072 fix: use account status for balance records instead of hardcoded 'active'
- Modified sync service to pass account status to balance persistence
- Updated database service to use account_status from balance data
- Fixed 8 balance records that had incorrect 'active' status
- All balance records now have consistent 'READY' status matching accounts
- Future balance records will inherit correct status from account data
2025-09-09 15:47:37 +01:00
Elisiário Couto
4315a6f747 fix: merge account details into balance data to prevent unknown/N/A values
- Modified sync service to include institution_id and iban in balance persistence
- Fixed data flow issue where balance records were missing account metadata
- Prevents future balance records from having 'unknown' bank or 'N/A' IBAN
- Successfully fixed 8 existing records with one-off script
2025-09-09 15:32:32 +01:00
Elisiário Couto
01744d56f0 feat: add automatic balance timestamp migration mechanism
- Add migration system to convert Unix timestamps to datetime strings
- Integrate migration into FastAPI lifespan for automatic startup execution
- Update balance persistence to use consistent ISO datetime format
- Fix mixed timestamp types causing API parsing issues
- Add comprehensive error handling and progress logging
- Successfully migrated 7522 balance records to consistent format
2025-09-09 15:22:22 +01:00
Elisiário Couto
cb9aa4540f Fix frontend health check to properly detect API unavailability
- Add isError to useQuery destructuring to handle network errors
- Improve health check query function to throw on HTTP errors
- Update status display logic to show 'Disconnected' when API is unreachable
- Ensure proper error handling for both network failures and HTTP status errors
2025-09-09 15:07:40 +01:00
6 changed files with 251 additions and 21 deletions

View File

@@ -30,10 +30,13 @@ export default function Dashboard() {
queryFn: apiClient.getAccounts,
});
const { data: healthStatus, isLoading: healthLoading } = useQuery({
const { data: healthStatus, isLoading: healthLoading, isError: healthError } = useQuery({
queryKey: ['health'],
queryFn: async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/health`);
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
return response.json();
},
refetchInterval: 30000, // Check every 30 seconds
@@ -146,16 +149,16 @@ export default function Dashboard() {
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span>
</>
) : healthStatus?.success ? (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
</>
) : (
) : healthError || !healthStatus?.success ? (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
</>
) : (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
</>
)}
</div>
</div>

View File

@@ -36,11 +36,14 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook")
else None,
telegram=TelegramConfig(
token="***" if telegram_config.get("token") else "",
chat_id=telegram_config.get("chat_id", 0),
token="***"
if (telegram_config.get("token") or telegram_config.get("api-key"))
else "",
chat_id=telegram_config.get("chat_id")
or telegram_config.get("chat-id", 0),
enabled=telegram_config.get("enabled", True),
)
if telegram_config.get("token")
if (telegram_config.get("token") or telegram_config.get("api-key"))
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", {}),
@@ -158,12 +161,24 @@ async def get_notification_services() -> APIResponse:
"telegram": {
"name": "Telegram",
"enabled": bool(
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
),
"configured": bool(
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
),
"active": notifications_config.get("telegram", {}).get("enabled", True),
},

View File

@@ -24,6 +24,17 @@ async def lifespan(app: FastAPI):
logger.error(f"Failed to load configuration: {e}")
raise
# Run database migrations
try:
from leggend.services.database_service import DatabaseService
db_service = DatabaseService()
await db_service.run_migrations_if_needed()
logger.info("Database migrations completed")
except Exception as e:
logger.error(f"Database migration failed: {e}")
raise
# Start background scheduler
scheduler.start()
logger.info("Background scheduler started")

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import List, Dict, Any, Optional
import sqlite3
from loguru import logger
@@ -251,6 +252,138 @@ class DatabaseService:
logger.error(f"Failed to get account details from database: {e}")
return None
async def run_migrations_if_needed(self):
"""Run all necessary database migrations"""
if not self.sqlite_enabled:
logger.info("SQLite database disabled, skipping migrations")
return
await self._migrate_balance_timestamps_if_needed()
async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
try:
if await self._check_balance_timestamp_migration_needed():
logger.info("Balance timestamp migration needed, starting...")
await self._migrate_balance_timestamps()
logger.info("Balance timestamp migration completed")
else:
logger.info("Balance timestamps are already consistent")
except Exception as e:
logger.error(f"Balance timestamp migration failed: {e}")
raise
async def _check_balance_timestamp_migration_needed(self) -> bool:
"""Check if balance timestamps need migration"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check for mixed timestamp types
cursor.execute("""
SELECT typeof(timestamp) as type, COUNT(*) as count
FROM balances
GROUP BY typeof(timestamp)
""")
types = cursor.fetchall()
conn.close()
# If we have both 'real' and 'text' types, migration is needed
type_names = [row[0] for row in types]
return "real" in type_names and "text" in type_names
except Exception as e:
logger.error(f"Failed to check migration status: {e}")
return False
async def _migrate_balance_timestamps(self):
"""Convert all Unix timestamps to datetime strings"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Get all balances with REAL timestamps
cursor.execute("""
SELECT id, timestamp
FROM balances
WHERE typeof(timestamp) = 'real'
ORDER BY id
""")
unix_records = cursor.fetchall()
total_records = len(unix_records)
if total_records == 0:
logger.info("No Unix timestamps found to migrate")
conn.close()
return
logger.info(
f"Migrating {total_records} balance records from Unix to datetime format"
)
# Convert and update in batches
batch_size = 100
migrated_count = 0
for i in range(0, total_records, batch_size):
batch = unix_records[i : i + batch_size]
for record_id, unix_timestamp in batch:
try:
# Convert Unix timestamp to datetime string
dt_string = self._unix_to_datetime_string(float(unix_timestamp))
# Update the record
cursor.execute(
"""
UPDATE balances
SET timestamp = ?
WHERE id = ?
""",
(dt_string, record_id),
)
migrated_count += 1
if migrated_count % 100 == 0:
logger.info(
f"Migrated {migrated_count}/{total_records} balance records"
)
except Exception as e:
logger.error(f"Failed to migrate record {record_id}: {e}")
continue
# Commit batch
conn.commit()
conn.close()
logger.info(f"Successfully migrated {migrated_count} balance records")
except Exception as e:
logger.error(f"Balance timestamp migration failed: {e}")
raise
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp)
return dt.isoformat()
async def _persist_balance_sqlite(
self, account_id: str, balance_data: Dict[str, Any]
) -> None:
@@ -313,12 +446,12 @@ class DatabaseService:
(
account_id,
balance_data.get("institution_id", "unknown"),
"active",
balance_data.get("account_status"),
balance_data.get("iban", "N/A"),
float(balance_amount["amount"]),
balance_amount["currency"],
balance["balanceType"],
datetime.now(),
datetime.now().isoformat(),
),
)
except sqlite3.IntegrityError:

View File

@@ -95,7 +95,8 @@ class NotificationService:
telegram_config = self.notifications_config.get("telegram", {})
return bool(
telegram_config.get("token")
and telegram_config.get("chat_id")
or telegram_config.get("api-key")
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
and telegram_config.get("enabled", True)
)
@@ -117,11 +118,67 @@ class NotificationService:
async def _send_discord_test(self, message: str) -> None:
"""Send Discord test notification"""
logger.info(f"Sending Discord test: {message}")
try:
from leggen.notifications.discord import send_expire_notification
import click
# Create a mock context with the webhook
ctx = click.Context(click.Command("test"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send test notification using the actual implementation
test_notification = {
"bank": "Test",
"requisition_id": "test-123",
"status": "active",
"days_left": 30,
}
send_expire_notification(ctx, test_notification)
logger.info(f"Discord test notification sent: {message}")
except Exception as e:
logger.error(f"Failed to send Discord test notification: {e}")
raise
async def _send_telegram_test(self, message: str) -> None:
"""Send Telegram test notification"""
logger.info(f"Sending Telegram test: {message}")
try:
from leggen.notifications.telegram import send_expire_notification
import click
# Create a mock context with the telegram config
ctx = click.Context(click.Command("test"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token")
or telegram_config.get("api-key"),
"chat-id": telegram_config.get("chat_id")
or telegram_config.get("chat-id"),
}
}
}
# Send test notification using the actual implementation
test_notification = {
"bank": "Test",
"requisition_id": "test-123",
"status": "active",
"days_left": 30,
}
send_expire_notification(ctx, test_notification)
logger.info(f"Telegram test notification sent: {message}")
except Exception as e:
logger.error(f"Failed to send Telegram test notification: {e}")
raise
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
"""Send Discord expiry notification"""

View File

@@ -61,8 +61,19 @@ class SyncService:
# Get and save balances
balances = await self.gocardless.get_account_balances(account_id)
if balances:
await self.database.persist_balance(account_id, balances)
if balances and account_details:
# Merge account details into balances data for proper persistence
balances_with_account_info = balances.copy()
balances_with_account_info["institution_id"] = (
account_details.get("institution_id")
)
balances_with_account_info["iban"] = account_details.get("iban")
balances_with_account_info["account_status"] = (
account_details.get("status")
)
await self.database.persist_balance(
account_id, balances_with_account_info
)
balances_updated += len(balances.get("balances", []))
# Get and save transactions