Compare commits

..

6 Commits

Author SHA1 Message Date
Elisiário Couto
dc3522220a chore(ci): Bump version to 2025.9.23 2025-09-25 00:34:45 +01:00
Elisiário Couto
1693b3a50d Resolve test issues. 2025-09-25 00:02:42 +01:00
Elisiário Couto
460c5af6ea fix: Correct sync trigger types from manual to scheduled/retry.
Fixed scheduled syncs being incorrectly saved as "manual" in database.
Now properly identifies scheduled syncs as "scheduled" and retry
attempts as "retry". Updated frontend to capitalize trigger type
badges for better display.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:58:43 +01:00
Elisiário Couto
5a8614e019 Small fixes. 2025-09-24 23:52:51 +01:00
Elisiário Couto
ae5d034d4b fix(cli): Fix API URL handling for subpaths and improve client robustness.
- Automatically append /api/v1 to base URL if not present
- Fix URL construction to handle subpaths correctly
- Update health check to parse new nested response format
- Refactor bank delete command to use API client instead of direct requests
- Remove redundant /api/v1 prefixes from endpoint calls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:52:34 +01:00
copilot-swe-agent[bot]
d4edf69f2c feat(frontend): Add version-based cache invalidation for PWA updates
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-24 21:46:12 +01:00
14 changed files with 164 additions and 42 deletions

View File

@@ -1,4 +1,32 @@
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.22 (2025/09/24) ## 2025.9.22 (2025/09/24)
### Bug Fixes ### Bug Fixes

View File

@@ -210,7 +210,7 @@ export default function System() {
: "Sync Failed"} : "Sync Failed"}
</h4> </h4>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{operation.trigger_type} {operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
</Badge> </Badge>
</div> </div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground"> <div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
@@ -272,7 +272,7 @@ export default function System() {
: "Sync Failed"} : "Sync Failed"}
</h4> </h4>
<Badge variant="outline" className="text-xs mt-1"> <Badge variant="outline" className="text-xs mt-1">
{operation.trigger_type} {operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
interface PWAUpdate { interface PWAUpdate {
updateAvailable: boolean; updateAvailable: boolean;
updateSW: () => Promise<void>; updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
} }
export function usePWA(): PWAUpdate { export function usePWA(): PWAUpdate {
@@ -11,6 +12,33 @@ export function usePWA(): PWAUpdate {
() => async () => {}, () => async () => {},
); );
const forceReload = async (): Promise<void> => {
try {
// Clear all caches
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
console.log("All caches cleared");
}
// Unregister service worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(registration => registration.unregister()));
console.log("All service workers unregistered");
}
// Force reload
window.location.reload();
} catch (error) {
console.error("Error during force reload:", error);
// Fallback: just reload the page
window.location.reload();
}
};
useEffect(() => { useEffect(() => {
// Check if SW registration is available // Check if SW registration is available
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
@@ -37,5 +65,6 @@ export function usePWA(): PWAUpdate {
return { return {
updateAvailable, updateAvailable,
updateSW, updateSW,
forceReload,
}; };
} }

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
const VERSION_STORAGE_KEY = "leggen_app_version";
export function useVersionCheck(forceReload: () => Promise<void>) {
const {
data: healthStatus,
isSuccess: healthSuccess,
} = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh version checks
});
useEffect(() => {
if (healthSuccess && healthStatus?.version) {
const currentVersion = healthStatus.version;
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion && storedVersion !== currentVersion) {
console.log(`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`);
console.log("Clearing cache and reloading...");
// Update stored version first
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
// Force reload to clear cache
forceReload();
} else if (!storedVersion) {
// First time loading, store the version
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
console.log(`Version stored: ${currentVersion}`);
}
}
}, [healthSuccess, healthStatus?.version, forceReload]);
}

View File

@@ -3,10 +3,14 @@ import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader"; import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts"; import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA"; import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
function RootLayout() { function RootLayout() {
const { updateAvailable, updateSW } = usePWA(); const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => { const handlePWAInstall = () => {
console.log("PWA installed successfully"); console.log("PWA installed successfully");

View File

@@ -1,12 +1,12 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; import { tanstackRouter } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
TanStackRouterVite(), tanstackRouter(),
react(), react(),
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",

View File

@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
duration_seconds: Optional[float] = None duration_seconds: Optional[float] = None
errors: list[str] = [] errors: list[str] = []
logs: list[str] = [] logs: list[str] = []
trigger_type: str = "manual" # manual, scheduled, api trigger_type: str = "manual" # manual, scheduled, retry, api
class Config: class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None} json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -1,6 +1,6 @@
import os import os
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin, urlparse
import requests import requests
@@ -13,11 +13,22 @@ class LeggenAPIClient:
base_url: str base_url: str
def __init__(self, base_url: Optional[str] = None): def __init__(self, base_url: Optional[str] = None):
self.base_url = ( raw_url = (
base_url base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000") or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "http://localhost:8000" or "http://localhost:8000"
) )
# Ensure base_url includes /api/v1 path if not already present
parsed = urlparse(raw_url)
if not parsed.path or parsed.path == "/":
# No path or just root, add /api/v1
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
elif not parsed.path.startswith("/api/v1"):
# Has a path but not /api/v1, add it
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
else:
# Already has /api/v1 path
self.base_url = raw_url.rstrip("/")
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"} {"Content-Type": "application/json", "Accept": "application/json"}
@@ -25,7 +36,14 @@ class LeggenAPIClient:
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API""" """Make HTTP request to the API"""
url = urljoin(self.base_url, endpoint) # Construct URL by joining base_url with endpoint
# Handle both relative endpoints (starting with /) and paths
if endpoint.startswith("/"):
# Absolute endpoint path - append to base_url
url = f"{self.base_url}{endpoint}"
else:
# Relative endpoint, use urljoin
url = urljoin(f"{self.base_url}/", endpoint)
try: try:
response = self.session.request(method, url, **kwargs) response = self.session.request(method, url, **kwargs)
@@ -52,7 +70,9 @@ class LeggenAPIClient:
"""Check if the leggen server is healthy""" """Check if the leggen server is healthy"""
try: try:
response = self._make_request("GET", "/health") response = self._make_request("GET", "/health")
return response.get("status") == "healthy" # The API now returns nested data structure
data = response.get("data", {})
return data.get("status") == "healthy"
except Exception: except Exception:
return False return False
@@ -60,7 +80,7 @@ class LeggenAPIClient:
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
"""Get bank institutions for a country""" """Get bank institutions for a country"""
response = self._make_request( response = self._make_request(
"GET", "/api/v1/banks/institutions", params={"country": country} "GET", "/banks/institutions", params={"country": country}
) )
return response.get("data", []) return response.get("data", [])
@@ -70,35 +90,35 @@ class LeggenAPIClient:
"""Connect to a bank""" """Connect to a bank"""
response = self._make_request( response = self._make_request(
"POST", "POST",
"/api/v1/banks/connect", "/banks/connect",
json={"institution_id": institution_id, "redirect_url": redirect_url}, json={"institution_id": institution_id, "redirect_url": redirect_url},
) )
return response.get("data", {}) return response.get("data", {})
def get_bank_status(self) -> List[Dict[str, Any]]: def get_bank_status(self) -> List[Dict[str, Any]]:
"""Get bank connection status""" """Get bank connection status"""
response = self._make_request("GET", "/api/v1/banks/status") response = self._make_request("GET", "/banks/status")
return response.get("data", []) return response.get("data", [])
def get_supported_countries(self) -> List[Dict[str, Any]]: def get_supported_countries(self) -> List[Dict[str, Any]]:
"""Get supported countries""" """Get supported countries"""
response = self._make_request("GET", "/api/v1/banks/countries") response = self._make_request("GET", "/banks/countries")
return response.get("data", []) return response.get("data", [])
# Account endpoints # Account endpoints
def get_accounts(self) -> List[Dict[str, Any]]: def get_accounts(self) -> List[Dict[str, Any]]:
"""Get all accounts""" """Get all accounts"""
response = self._make_request("GET", "/api/v1/accounts") response = self._make_request("GET", "/accounts")
return response.get("data", []) return response.get("data", [])
def get_account_details(self, account_id: str) -> Dict[str, Any]: def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details""" """Get account details"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}") response = self._make_request("GET", f"/accounts/{account_id}")
return response.get("data", {}) return response.get("data", {})
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]: def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
"""Get account balances""" """Get account balances"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances") response = self._make_request("GET", f"/accounts/{account_id}/balances")
return response.get("data", []) return response.get("data", [])
def get_account_transactions( def get_account_transactions(
@@ -107,7 +127,7 @@ class LeggenAPIClient:
"""Get account transactions""" """Get account transactions"""
response = self._make_request( response = self._make_request(
"GET", "GET",
f"/api/v1/accounts/{account_id}/transactions", f"/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only}, params={"limit": limit, "summary_only": summary_only},
) )
return response.get("data", []) return response.get("data", [])
@@ -120,7 +140,7 @@ class LeggenAPIClient:
params = {"limit": limit, "summary_only": summary_only} params = {"limit": limit, "summary_only": summary_only}
params.update(filters) params.update(filters)
response = self._make_request("GET", "/api/v1/transactions", params=params) response = self._make_request("GET", "/transactions", params=params)
return response.get("data", []) return response.get("data", [])
def get_transaction_stats( def get_transaction_stats(
@@ -131,15 +151,13 @@ class LeggenAPIClient:
if account_id: if account_id:
params["account_id"] = account_id params["account_id"] = account_id
response = self._make_request( response = self._make_request("GET", "/transactions/stats", params=params)
"GET", "/api/v1/transactions/stats", params=params
)
return response.get("data", {}) return response.get("data", {})
# Sync endpoints # Sync endpoints
def get_sync_status(self) -> Dict[str, Any]: def get_sync_status(self) -> Dict[str, Any]:
"""Get sync status""" """Get sync status"""
response = self._make_request("GET", "/api/v1/sync/status") response = self._make_request("GET", "/sync/status")
return response.get("data", {}) return response.get("data", {})
def trigger_sync( def trigger_sync(
@@ -150,7 +168,7 @@ class LeggenAPIClient:
if account_ids: if account_ids:
data["account_ids"] = account_ids data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync", json=data) response = self._make_request("POST", "/sync", json=data)
return response.get("data", {}) return response.get("data", {})
def sync_now( def sync_now(
@@ -161,12 +179,12 @@ class LeggenAPIClient:
if account_ids: if account_ids:
data["account_ids"] = account_ids data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync/now", json=data) response = self._make_request("POST", "/sync/now", json=data)
return response.get("data", {}) return response.get("data", {})
def get_scheduler_config(self) -> Dict[str, Any]: def get_scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration""" """Get scheduler configuration"""
response = self._make_request("GET", "/api/v1/sync/scheduler") response = self._make_request("GET", "/sync/scheduler")
return response.get("data", {}) return response.get("data", {})
def update_scheduler_config( def update_scheduler_config(
@@ -185,5 +203,5 @@ class LeggenAPIClient:
if cron: if cron:
data["cron"] = cron data["cron"] = cron
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data) response = self._make_request("PUT", "/sync/scheduler", json=data)
return response.get("data", {}) return response.get("data", {})

View File

@@ -102,12 +102,14 @@ class BackgroundScheduler:
async def _run_sync(self, retry_count: int = 0): async def _run_sync(self, retry_count: int = 0):
"""Run sync with enhanced error handling and retry logic""" """Run sync with enhanced error handling and retry logic"""
try: try:
logger.info("Starting scheduled sync job") trigger_type = "retry" if retry_count > 0 else "scheduled"
await self.sync_service.sync_all_accounts() logger.info(f"Starting {trigger_type} sync job")
logger.info("Scheduled sync job completed successfully") await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
except Exception as e: except Exception as e:
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.error( logger.error(
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}" f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
) )
# Send notification about the failure # Send notification about the failure

View File

@@ -1,5 +1,6 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.utils.text import info, success from leggen.utils.text import info, success
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
Check `leggen status` to get the REQUISITION_ID Check `leggen status` to get the REQUISITION_ID
""" """
import requests api_client = LeggenAPIClient(ctx.obj.get("api_url"))
info(f"Deleting Bank Requisition: {requisition_id}") info(f"Deleting Bank Requisition: {requisition_id}")
api_url = ctx.obj.get("api_url", "http://localhost:8000") # Use API client to make the delete request
res = requests.delete(f"{api_url}/requisitions/{requisition_id}") api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
res.raise_for_status()
success(f"Bank Requisition {requisition_id} deleted") success(f"Bank Requisition {requisition_id} deleted")

View File

@@ -39,13 +39,11 @@ class Config:
try: try:
with open(config_path, "rb") as f: with open(config_path, "rb") as f:
raw_config = tomllib.load(f) raw_config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
# Validate configuration using Pydantic # Validate configuration using Pydantic
try: try:
self._config_model = ConfigModel(**raw_config) self._config_model = ConfigModel(**raw_config)
self._config = self._config_model.dict(by_alias=True, exclude_none=True) self._config = self._config_model.dict(by_alias=True, exclude_none=True)
logger.info("Configuration validation successful")
except ValidationError as e: except ValidationError as e:
logger.error(f"Configuration validation failed: {e}") logger.error(f"Configuration validation failed: {e}")
raise ValueError(f"Invalid configuration: {e}") from e raise ValueError(f"Invalid configuration: {e}") from e

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.22" version = "2025.9.23"
description = "An Open Banking CLI" description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }] authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0" requires-python = "~=3.13.0"

View File

@@ -18,7 +18,10 @@ class TestLeggenAPIClient:
client = LeggenAPIClient("http://localhost:8000") client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m: with requests_mock.Mocker() as m:
m.get("http://localhost:8000/health", json={"status": "healthy"}) m.get(
"http://localhost:8000/api/v1/health",
json={"data": {"status": "healthy"}},
)
result = client.health_check() result = client.health_check()
assert result is True assert result is True
@@ -112,13 +115,13 @@ class TestLeggenAPIClient:
custom_url = "http://custom-host:9000" custom_url = "http://custom-host:9000"
client = LeggenAPIClient(custom_url) client = LeggenAPIClient(custom_url)
assert client.base_url == custom_url assert client.base_url == f"{custom_url}/api/v1"
def test_environment_variable_url(self): def test_environment_variable_url(self):
"""Test using environment variable for API URL.""" """Test using environment variable for API URL."""
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}): with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
client = LeggenAPIClient() client = LeggenAPIClient()
assert client.base_url == "http://env-host:7000" assert client.base_url == "http://env-host:7000/api/v1"
def test_sync_with_options(self): def test_sync_with_options(self):
"""Test sync with various options.""" """Test sync with various options."""

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]] [[package]]
name = "leggen" name = "leggen"
version = "2025.9.22" version = "2025.9.23"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },