Compare commits

..

4 Commits

Author SHA1 Message Date
Elisiário Couto
a8f704129b chore: Add pre-commit instructions to AGENTS.md. 2025-09-24 15:20:50 +01:00
Elisiário Couto
62cd55e48f feat(frontend): Improve System page and TransactionsTable UX.
System page improvements:
- Add View Logs button to each sync operation with modal dialog
- Implement responsive design for mobile devices
- Remove redundant error count indicators
- Show full transaction text on mobile ("X new transactions")

TransactionsTable improvements:
- Use display_name instead of name • institution_id format
- Show only clean account display names in transaction rows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:20:08 +01:00
Elisiário Couto
e4e3f885ea feat(api): Add separate sync failure notifications.
- Create dedicated sync failure notification templates for Telegram and Discord
- Add send_sync_failure_notification method to NotificationService
- Update scheduler to use proper notification method instead of expiry notifications
- Telegram: Shows error details with retry count and failure status
- Discord: Color-coded embeds (orange for retries, red for final failures)
- Fixes KeyError: 'bank' when sync failures occur

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:04:01 +01:00
Elisiário Couto
36d698f7ce fix(api): Add automatic token refresh on 401 errors in GoCardless service.
- Add _make_authenticated_request helper that automatically handles 401 errors
- Clear token cache and retry once when encountering expired tokens
- Refactor all API methods to use centralized request handling
- Fix banks API to properly handle institutions response structure
- Eliminates need for container restarts when tokens expire

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 14:58:45 +01:00
10 changed files with 395 additions and 116 deletions

View File

@@ -138,3 +138,4 @@ This repository follows conventional changelog practices. Refer to `CONTRIBUTING
- Commit message format and scoping - Commit message format and scoping
- Release process using `scripts/release.sh` - Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install` - Pre-commit hooks setup with `pre-commit install`
- When the pre-commit fails, the commit is canceled

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { import {
RefreshCw, RefreshCw,
AlertCircle, AlertCircle,
@@ -7,6 +8,7 @@ import {
Clock, Clock,
TrendingUp, TrendingUp,
User, User,
FileText,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { import {
@@ -19,7 +21,73 @@ import {
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import type { SyncOperationsResponse } from "../types/api"; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import type { SyncOperationsResponse, SyncOperation } from "../types/api";
// Component for viewing sync operation logs
function LogsDialog({ operation }: { operation: SyncOperation }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="shrink-0">
<FileText className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">View Logs</span>
<span className="sm:hidden">Logs</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Sync Operation Logs</DialogTitle>
<DialogDescription>
Operation #{operation.id} - Started at{" "}
{new Date(operation.started_at).toLocaleString()}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded border p-4">
<div className="space-y-2">
{operation.logs.length === 0 ? (
<p className="text-muted-foreground text-sm">No logs available</p>
) : (
operation.logs.map((log, index) => (
<div
key={index}
className="text-sm font-mono bg-muted/50 p-2 rounded text-wrap break-all"
>
{log}
</div>
))
)}
</div>
{operation.errors.length > 0 && (
<>
<div className="mt-4 mb-2 text-sm font-semibold text-destructive">
Errors:
</div>
<div className="space-y-2">
{operation.errors.map((error, index) => (
<div
key={index}
className="text-sm font-mono bg-destructive/10 border border-destructive/20 p-2 rounded text-wrap break-all text-destructive"
>
{error}
</div>
))}
</div>
</>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
export default function System() { export default function System() {
const { const {
@@ -111,68 +179,128 @@ export default function System() {
return ( return (
<div <div
key={operation.id} key={operation.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors" className="border rounded-lg hover:bg-accent transition-colors"
> >
<div className="flex items-center space-x-4"> {/* Desktop Layout */}
<div <div className="hidden md:flex items-center justify-between p-4">
className={`p-2 rounded-full ${ <div className="flex items-center space-x-4">
isRunning <div
? "bg-blue-100 text-blue-600" className={`p-2 rounded-full ${
: operation.success isRunning
? "bg-green-100 text-green-600" ? "bg-blue-100 text-blue-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success : operation.success
? "Sync Completed" ? "bg-green-100 text-green-600"
: "Sync Failed"} : "bg-red-100 text-red-600"
</h4> }`}
<Badge variant="outline" className="text-xs"> >
{operation.trigger_type} {isRunning ? (
</Badge> <RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div> </div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground"> <div>
<span className="flex items-center space-x-1"> <div className="flex items-center space-x-2">
<Clock className="h-3 w-3" /> <h4 className="text-sm font-medium text-foreground">
<span> {isRunning
{startedAt.toLocaleDateString()}{" "} ? "Sync Running"
{startedAt.toLocaleTimeString()} : operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type}
</Badge>
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
</span> </span>
</span> {duration && <span>Duration: {duration}</span>}
{duration && <span>Duration: {duration}</span>} </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4">
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-2 mt-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
</div>
<LogsDialog operation={operation} />
</div>
</div> </div>
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2"> {/* Mobile Layout */}
<User className="h-3 w-3" /> <div className="md:hidden p-4 space-y-3">
<span>{operation.accounts_processed} accounts</span> <div className="flex items-start justify-between">
</div> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 mt-1"> <div
<TrendingUp className="h-3 w-3" /> className={`p-2 rounded-full ${
<span> isRunning
{operation.transactions_added} new transactions ? "bg-blue-100 text-blue-600"
</span> : operation.success
</div> ? "bg-green-100 text-green-600"
{operation.errors.length > 0 && ( : "bg-red-100 text-red-600"
<div className="flex items-center space-x-2 mt-1 text-red-600"> }`}
<AlertCircle className="h-3 w-3" /> >
<span>{operation.errors.length} errors</span> {isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs mt-1">
{operation.trigger_type}
</Badge>
</div>
</div> </div>
)} <LogsDialog operation={operation} />
</div>
<div className="text-xs text-muted-foreground space-y-2">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
{duration && <span className="ml-2"> {duration}</span>}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center space-x-1">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-1">
<TrendingUp className="h-3 w-3" />
<span>{operation.transactions_added} new transactions</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -190,8 +190,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
{account && ( {account && (
<p className="truncate"> <p className="truncate">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || transaction.debtor_name) && ( {(transaction.creditor_name || transaction.debtor_name) && (
@@ -486,8 +485,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1 mt-1"> <div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && ( {account && (
<p className="break-words"> <p className="break-words">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || {(transaction.creditor_name ||

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
className
)}
{...props}
>
{children}
</div>
));
ScrollArea.displayName = "ScrollArea";
export { ScrollArea };

View File

@@ -32,7 +32,7 @@ async def get_bank_institutions(
countries=inst["countries"], countries=inst["countries"],
logo=inst.get("logo"), logo=inst.get("logo"),
) )
for inst in institutions_data for inst in institutions_data.get("results", [])
] ]
return APIResponse( return APIResponse(

View File

@@ -112,7 +112,7 @@ class BackgroundScheduler:
# Send notification about the failure # Send notification about the failure
try: try:
await self.notification_service.send_expiry_notification( await self.notification_service.send_sync_failure_notification(
{ {
"type": "sync_failure", "type": "sync_failure",
"error": str(e), "error": str(e),
@@ -145,7 +145,7 @@ class BackgroundScheduler:
logger.error("Maximum retries exceeded for sync job") logger.error("Maximum retries exceeded for sync job")
# Send final failure notification # Send final failure notification
try: try:
await self.notification_service.send_expiry_notification( await self.notification_service.send_sync_failure_notification(
{ {
"type": "sync_final_failure", "type": "sync_final_failure",
"error": str(e), "error": str(e),

View File

@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
response.raise_for_status() response.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
info("Sending sync failure notification to Discord")
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
# Determine color and title based on failure type
if notification.get("type") == "sync_final_failure":
color = "ff0000" # Red for final failure
title = "🚨 Sync Final Failure"
description = (
f"Sync failed permanently after {notification['retry_count']} attempts"
)
else:
color = "ffaa00" # Orange for retry
title = "⚠️ Sync Failure"
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
embed = DiscordEmbed(
title=title,
description=description,
color=color,
)
embed.set_author(
name="Leggen",
url="https://github.com/elisiariocouto/leggen",
)
embed.add_embed_field(
name="Error",
value=notification["error"][:1024], # Discord has field value limits
inline=False,
)
embed.set_footer(text="Sync failure notification")
embed.set_timestamp()
webhook.add_embed(embed)
response = webhook.execute()
try:
response.raise_for_status()
except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e

View File

@@ -79,3 +79,38 @@ def send_transaction_message(ctx: click.Context, transactions: list):
res.raise_for_status() res.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending sync failure notification to Telegram")
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message += "*Sync Failed*\n\n"
message += escape_markdown(f"Error: {notification['error']}\n")
if notification.get("type") == "sync_final_failure":
message += escape_markdown(
f"❌ Final failure after {notification['retry_count']} attempts\n"
)
else:
message += escape_markdown(
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
)
message += escape_markdown("Will retry automatically...\n")
res = requests.post(
bot_url,
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "MarkdownV2",
},
)
try:
res.raise_for_status()
except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e

View File

@@ -1,6 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict
import httpx import httpx
from loguru import logger from loguru import logger
@@ -30,6 +30,27 @@ class GoCardlessService:
) )
self._token = None self._token = None
async def _make_authenticated_request(
self, method: str, url: str, **kwargs
) -> Dict[str, Any]:
"""Make authenticated request with automatic token refresh on 401"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
# If we get 401, clear token cache and retry once
if response.status_code == 401:
logger.warning("Got 401, clearing token cache and retrying")
self._token = None
headers = await self._get_auth_headers()
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def _get_auth_headers(self) -> Dict[str, str]: async def _get_auth_headers(self) -> Dict[str, str]:
"""Get authentication headers for GoCardless API""" """Get authentication headers for GoCardless API"""
token = await self._get_token() token = await self._get_token()
@@ -102,74 +123,42 @@ class GoCardlessService:
with open(auth_file, "w") as f: with open(auth_file, "w") as f:
json.dump(auth_data, f) json.dump(auth_data, f)
async def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: async def get_institutions(self, country: str = "PT") -> Dict[str, Any]:
"""Get available bank institutions for a country""" """Get available bank institutions for a country"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/institutions/", params={"country": country}
response = await client.get( )
f"{self.base_url}/institutions/",
headers=headers,
params={"country": country},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def create_requisition( async def create_requisition(
self, institution_id: str, redirect_url: str self, institution_id: str, redirect_url: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a bank connection requisition""" """Create a bank connection requisition"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "POST",
response = await client.post( f"{self.base_url}/requisitions/",
f"{self.base_url}/requisitions/", json={"institution_id": institution_id, "redirect": redirect_url},
headers=headers, )
json={"institution_id": institution_id, "redirect": redirect_url},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_requisitions(self) -> Dict[str, Any]: async def get_requisitions(self) -> Dict[str, Any]:
"""Get all requisitions""" """Get all requisitions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/requisitions/"
response = await client.get( )
f"{self.base_url}/requisitions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_details(self, account_id: str) -> Dict[str, Any]: async def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details""" """Get account details"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_balances(self, account_id: str) -> Dict[str, Any]: async def get_account_balances(self, account_id: str) -> Dict[str, Any]:
"""Get account balances""" """Get account balances"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/balances/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/balances/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_transactions(self, account_id: str) -> Dict[str, Any]: async def get_account_transactions(self, account_id: str) -> Dict[str, Any]:
"""Get account transactions""" """Get account transactions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/transactions/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/transactions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()

View File

@@ -289,3 +289,69 @@ class NotificationService:
except Exception as e: except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}") logger.error(f"Failed to send Telegram expiry notification: {e}")
raise raise
async def send_sync_failure_notification(
self, notification_data: Dict[str, Any]
) -> None:
"""Send notification about sync failure"""
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
async def _send_discord_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Discord sync failure notification"""
try:
import click
from leggen.notifications.discord import send_sync_failure_notification
# Create a mock context with the webhook
ctx = click.Context(click.Command("sync_failure"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Discord sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
raise
async def _send_telegram_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Telegram sync failure notification"""
try:
import click
from leggen.notifications.telegram import send_sync_failure_notification
# Create a mock context with the telegram config
ctx = click.Context(click.Command("sync_failure"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
raise