mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3522220a | ||
|
|
1693b3a50d | ||
|
|
460c5af6ea | ||
|
|
5a8614e019 | ||
|
|
ae5d034d4b | ||
|
|
d4edf69f2c | ||
|
|
d3a1696d4d | ||
|
|
24792744f9 | ||
|
|
b9ca74e7e6 | ||
|
|
a8f704129b | ||
|
|
62cd55e48f | ||
|
|
e4e3f885ea | ||
|
|
36d698f7ce | ||
|
|
d211a14703 | ||
|
|
c332642e64 | ||
|
|
27f3f2dbba |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -165,3 +165,4 @@ leggen.db
|
|||||||
*.db
|
*.db
|
||||||
config.toml
|
config.toml
|
||||||
.claude/
|
.claude/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
"mcp"
|
"mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browsermcp": {
|
"playwright": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
"@browsermcp/mcp@latest"
|
"@playwright/mcp@latest"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,4 +1,100 @@
|
|||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||||
|
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||||
|
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||||
|
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.22 (2025/09/24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||||
|
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||||
|
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||||
|
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.21 (2025/09/22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.21 (2025/09/22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.20 (2025/09/22)
|
## 2025.9.20 (2025/09/22)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
@@ -4195,6 +4196,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default function AccountSettings() {
|
|||||||
|
|
||||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
|
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -194,8 +195,20 @@ export default function AccountSettings() {
|
|||||||
{/* Mobile layout - stack vertically */}
|
{/* Mobile layout - stack vertically */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
{account.logo && !failedImages.has(account.id) ? (
|
||||||
|
<img
|
||||||
|
src={account.logo}
|
||||||
|
alt={`${account.institution_id} logo`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onError={() => {
|
||||||
|
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||||
|
setFailedImages(prev => new Set([...prev, account.id]));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{editingAccountId === account.id ? (
|
{editingAccountId === account.id ? (
|
||||||
|
|||||||
181
frontend/src/components/DiscordConfigDrawer.tsx
Normal file
181
frontend/src/components/DiscordConfigDrawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { MessageSquare, TestTube } from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Switch } from "./ui/switch";
|
||||||
|
import { EditButton } from "./ui/edit-button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "./ui/drawer";
|
||||||
|
import type { NotificationSettings, DiscordConfig } from "../types/api";
|
||||||
|
|
||||||
|
interface DiscordConfigDrawerProps {
|
||||||
|
settings: NotificationSettings | undefined;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiscordConfigDrawer({
|
||||||
|
settings,
|
||||||
|
trigger,
|
||||||
|
}: DiscordConfigDrawerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [config, setConfig] = useState<DiscordConfig>({
|
||||||
|
webhook: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.discord) {
|
||||||
|
setConfig({ ...settings.discord });
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (discordConfig: DiscordConfig) =>
|
||||||
|
apiClient.updateNotificationSettings({
|
||||||
|
...settings,
|
||||||
|
discord: discordConfig,
|
||||||
|
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update Discord configuration:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.testNotification({
|
||||||
|
service: "discord",
|
||||||
|
message: "Test notification from Leggen - Discord configuration is working!"
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
console.log("Test Discord notification sent successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to send test Discord notification:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateMutation.mutate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = () => {
|
||||||
|
testMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConfigValid = config.webhook.trim().length > 0 && config.webhook.includes('discord.com/api/webhooks');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{trigger || <EditButton />}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="flex items-center space-x-2">
|
||||||
|
<MessageSquare className="h-5 w-5 text-primary" />
|
||||||
|
<span>Discord Configuration</span>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Configure Discord webhook notifications for transaction alerts
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-medium">Enable Discord Notifications</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Webhook URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="discord-webhook">Discord Webhook URL</Label>
|
||||||
|
<Input
|
||||||
|
id="discord-webhook"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
value={config.webhook}
|
||||||
|
onChange={(e) => setConfig({ ...config, webhook: e.target.value })}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create a webhook in your Discord server settings under Integrations → Webhooks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Status */}
|
||||||
|
{config.enabled && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isConfigValid ? 'Configuration Valid' : 'Invalid Webhook URL'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isConfigValid && config.webhook.trim().length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Please enter a valid Discord webhook URL
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DrawerFooter className="px-0">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
||||||
|
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
||||||
|
</Button>
|
||||||
|
{config.enabled && isConfigValid && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2" />
|
||||||
|
Test
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
frontend/src/components/NotificationFiltersDrawer.tsx
Normal file
225
frontend/src/components/NotificationFiltersDrawer.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { EditButton } from "./ui/edit-button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "./ui/drawer";
|
||||||
|
import type { NotificationSettings, NotificationFilters } from "../types/api";
|
||||||
|
|
||||||
|
interface NotificationFiltersDrawerProps {
|
||||||
|
settings: NotificationSettings | undefined;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationFiltersDrawer({
|
||||||
|
settings,
|
||||||
|
trigger,
|
||||||
|
}: NotificationFiltersDrawerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<NotificationFilters>({
|
||||||
|
case_insensitive: [],
|
||||||
|
case_sensitive: [],
|
||||||
|
});
|
||||||
|
const [newCaseInsensitive, setNewCaseInsensitive] = useState("");
|
||||||
|
const [newCaseSensitive, setNewCaseSensitive] = useState("");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.filters) {
|
||||||
|
setFilters({
|
||||||
|
case_insensitive: [...(settings.filters.case_insensitive || [])],
|
||||||
|
case_sensitive: [...(settings.filters.case_sensitive || [])],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (updatedFilters: NotificationFilters) =>
|
||||||
|
apiClient.updateNotificationSettings({
|
||||||
|
...settings,
|
||||||
|
filters: updatedFilters,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update notification filters:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateMutation.mutate(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCaseInsensitiveFilter = () => {
|
||||||
|
if (newCaseInsensitive.trim() && !filters.case_insensitive.includes(newCaseInsensitive.trim())) {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
case_insensitive: [...filters.case_insensitive, newCaseInsensitive.trim()],
|
||||||
|
});
|
||||||
|
setNewCaseInsensitive("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCaseSensitiveFilter = () => {
|
||||||
|
if (newCaseSensitive.trim() && !filters.case_sensitive?.includes(newCaseSensitive.trim())) {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
case_sensitive: [...(filters.case_sensitive || []), newCaseSensitive.trim()],
|
||||||
|
});
|
||||||
|
setNewCaseSensitive("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCaseInsensitiveFilter = (index: number) => {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
case_insensitive: filters.case_insensitive.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCaseSensitiveFilter = (index: number) => {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
case_sensitive: filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{trigger || <EditButton />}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Notification Filters</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Configure which transaction descriptions should trigger notifications
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
|
{/* Case Insensitive Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-medium">Case Insensitive Filters</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Filters that match regardless of capitalization (e.g., "AMAZON" matches "amazon")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add filter term..."
|
||||||
|
value={newCaseInsensitive}
|
||||||
|
onChange={(e) => setNewCaseInsensitive(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addCaseInsensitiveFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={addCaseInsensitiveFilter} size="sm">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
|
||||||
|
{filters.case_insensitive.length > 0 ? (
|
||||||
|
filters.case_insensitive.map((filter, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{filter}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCaseInsensitiveFilter(index)}
|
||||||
|
className="text-secondary-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">No filters added</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Case Sensitive Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-medium">Case Sensitive Filters</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Filters that match exactly as typed (e.g., "AMAZON" only matches "AMAZON")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add filter term..."
|
||||||
|
value={newCaseSensitive}
|
||||||
|
onChange={(e) => setNewCaseSensitive(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addCaseSensitiveFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={addCaseSensitiveFilter} size="sm">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
|
||||||
|
{filters.case_sensitive && filters.case_sensitive.length > 0 ? (
|
||||||
|
filters.case_sensitive.map((filter, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{filter}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCaseSensitiveFilter(index)}
|
||||||
|
className="text-secondary-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">No filters added</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter className="px-0">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? "Saving..." : "Save Filters"}
|
||||||
|
</Button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,10 +15,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
TestTube,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
User,
|
User,
|
||||||
CheckCircle,
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
@@ -31,18 +29,12 @@ import {
|
|||||||
} from "./ui/card";
|
} from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
import { Badge } from "./ui/badge";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "./ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||||
import AccountsSkeleton from "./AccountsSkeleton";
|
import AccountsSkeleton from "./AccountsSkeleton";
|
||||||
|
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||||
|
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||||
|
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Balance,
|
Balance,
|
||||||
@@ -87,10 +79,7 @@ const getStatusIndicator = (status: string) => {
|
|||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
const [testService, setTestService] = useState("");
|
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||||
const [testMessage, setTestMessage] = useState(
|
|
||||||
"Test notification from Leggen",
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -146,16 +135,6 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Notification mutations
|
// Notification mutations
|
||||||
const testMutation = useMutation({
|
|
||||||
mutationFn: apiClient.testNotification,
|
|
||||||
onSuccess: () => {
|
|
||||||
console.log("Test notification sent successfully");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to send test notification:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteServiceMutation = useMutation({
|
const deleteServiceMutation = useMutation({
|
||||||
mutationFn: apiClient.deleteNotificationService,
|
mutationFn: apiClient.deleteNotificationService,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -185,15 +164,6 @@ export default function Settings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Notification handlers
|
// Notification handlers
|
||||||
const handleTestNotification = () => {
|
|
||||||
if (!testService) return;
|
|
||||||
|
|
||||||
testMutation.mutate({
|
|
||||||
service: testService.toLowerCase(),
|
|
||||||
message: testMessage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteService = (serviceName: string) => {
|
const handleDeleteService = (serviceName: string) => {
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
@@ -311,8 +281,20 @@ export default function Settings() {
|
|||||||
{/* Mobile layout - stack vertically */}
|
{/* Mobile layout - stack vertically */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
{account.logo && !failedImages.has(account.id) ? (
|
||||||
|
<img
|
||||||
|
src={account.logo}
|
||||||
|
alt={`${account.institution_id} logo`}
|
||||||
|
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||||
|
onError={() => {
|
||||||
|
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||||
|
setFailedImages(prev => new Set([...prev, account.id]));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{editingAccountId === account.id ? (
|
{editingAccountId === account.id ? (
|
||||||
@@ -463,63 +445,6 @@ export default function Settings() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="notifications" className="space-y-6">
|
<TabsContent value="notifications" className="space-y-6">
|
||||||
{/* Test Notification Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<TestTube className="h-5 w-5 text-primary" />
|
|
||||||
<span>Test Notifications</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="service" className="text-foreground">
|
|
||||||
Service
|
|
||||||
</Label>
|
|
||||||
<Select value={testService} onValueChange={setTestService}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a service..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{services?.map((service) => (
|
|
||||||
<SelectItem key={service.name} value={service.name}>
|
|
||||||
{service.name}{" "}
|
|
||||||
{service.enabled ? "(Enabled)" : "(Disabled)"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="message" className="text-foreground">
|
|
||||||
Message
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="message"
|
|
||||||
type="text"
|
|
||||||
value={testMessage}
|
|
||||||
onChange={(e) => setTestMessage(e.target.value)}
|
|
||||||
placeholder="Test message..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestNotification}
|
|
||||||
disabled={!testService || testMutation.isPending}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 mr-2" />
|
|
||||||
{testMutation.isPending
|
|
||||||
? "Sending..."
|
|
||||||
: "Send Test Notification"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notification Services */}
|
{/* Notification Services */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -564,45 +489,48 @@ export default function Settings() {
|
|||||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h4 className="text-lg font-medium text-foreground capitalize">
|
<div className="flex items-center space-x-3">
|
||||||
{service.name}
|
<h4 className="text-lg font-medium text-foreground capitalize">
|
||||||
</h4>
|
{service.name}
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
</h4>
|
||||||
<Badge
|
<div className="flex items-center space-x-2">
|
||||||
variant={
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
service.enabled ? "default" : "destructive"
|
service.enabled && service.configured
|
||||||
}
|
? 'bg-green-500'
|
||||||
>
|
: service.enabled
|
||||||
{service.enabled ? (
|
? 'bg-amber-500'
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
: 'bg-muted-foreground'
|
||||||
) : (
|
}`} />
|
||||||
<AlertCircle className="h-3 w-3 mr-1" />
|
<span className="text-sm text-muted-foreground">
|
||||||
)}
|
{service.enabled && service.configured
|
||||||
{service.enabled ? "Enabled" : "Disabled"}
|
? 'Active'
|
||||||
</Badge>
|
: service.enabled
|
||||||
<Badge
|
? 'Needs Configuration'
|
||||||
variant={
|
: 'Disabled'}
|
||||||
service.configured ? "secondary" : "outline"
|
</span>
|
||||||
}
|
</div>
|
||||||
>
|
|
||||||
{service.configured
|
|
||||||
? "Configured"
|
|
||||||
: "Not Configured"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center space-x-2">
|
||||||
onClick={() => handleDeleteService(service.name)}
|
{service.name.toLowerCase().includes("discord") ? (
|
||||||
disabled={deleteServiceMutation.isPending}
|
<DiscordConfigDrawer settings={notificationSettings} />
|
||||||
variant="ghost"
|
) : service.name.toLowerCase().includes("telegram") ? (
|
||||||
size="sm"
|
<TelegramConfigDrawer settings={notificationSettings} />
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
) : null}
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
onClick={() => handleDeleteService(service.name)}
|
||||||
|
disabled={deleteServiceMutation.isPending}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -611,60 +539,78 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notification Settings */}
|
{/* Notification Filters */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center space-x-2">
|
<div className="flex items-center justify-between">
|
||||||
<SettingsIcon className="h-5 w-5 text-primary" />
|
<CardTitle className="flex items-center space-x-2">
|
||||||
<span>Notification Settings</span>
|
<Filter className="h-5 w-5 text-primary" />
|
||||||
</CardTitle>
|
<span>Notification Filters</span>
|
||||||
|
</CardTitle>
|
||||||
|
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{notificationSettings && (
|
{notificationSettings?.filters ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="bg-muted rounded-md p-4">
|
||||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
Filters
|
<div>
|
||||||
</h4>
|
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||||
<div className="bg-muted rounded-md p-4">
|
Case Insensitive Filters
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
</Label>
|
||||||
<div>
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
{notificationSettings.filters.case_insensitive.length > 0 ? (
|
||||||
Case Insensitive Filters
|
notificationSettings.filters.case_insensitive.map((filter, index) => (
|
||||||
</Label>
|
<span
|
||||||
<p className="text-sm text-foreground">
|
key={index}
|
||||||
{notificationSettings.filters.case_insensitive
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
.length > 0
|
>
|
||||||
? notificationSettings.filters.case_insensitive.join(
|
{filter}
|
||||||
", ",
|
</span>
|
||||||
)
|
))
|
||||||
: "None"}
|
) : (
|
||||||
</p>
|
<p className="text-sm text-muted-foreground">None</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
<div>
|
||||||
Case Sensitive Filters
|
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||||
</Label>
|
Case Sensitive Filters
|
||||||
<p className="text-sm text-foreground">
|
</Label>
|
||||||
{notificationSettings.filters.case_sensitive &&
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
notificationSettings.filters.case_sensitive.length >
|
{notificationSettings.filters.case_sensitive &&
|
||||||
0
|
notificationSettings.filters.case_sensitive.length > 0 ? (
|
||||||
? notificationSettings.filters.case_sensitive.join(
|
notificationSettings.filters.case_sensitive.map((filter, index) => (
|
||||||
", ",
|
<span
|
||||||
)
|
key={index}
|
||||||
: "None"}
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
</p>
|
>
|
||||||
|
{filter}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">None</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="text-sm text-muted-foreground">
|
Filters determine which transaction descriptions will trigger notifications.
|
||||||
<p>
|
Add terms to exclude transactions containing those words.
|
||||||
Configure notification settings through your backend API
|
</p>
|
||||||
to customize filters and service configurations.
|
</div>
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<div className="text-center py-8">
|
||||||
|
<Filter className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No notification filters configured
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Set up filters to control which transactions trigger notifications.
|
||||||
|
</p>
|
||||||
|
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,6 +7,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 +20,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 +178,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.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
||||||
|
</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.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
198
frontend/src/components/TelegramConfigDrawer.tsx
Normal file
198
frontend/src/components/TelegramConfigDrawer.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Send, TestTube } from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Switch } from "./ui/switch";
|
||||||
|
import { EditButton } from "./ui/edit-button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "./ui/drawer";
|
||||||
|
import type { NotificationSettings, TelegramConfig } from "../types/api";
|
||||||
|
|
||||||
|
interface TelegramConfigDrawerProps {
|
||||||
|
settings: NotificationSettings | undefined;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelegramConfigDrawer({
|
||||||
|
settings,
|
||||||
|
trigger,
|
||||||
|
}: TelegramConfigDrawerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [config, setConfig] = useState<TelegramConfig>({
|
||||||
|
token: "",
|
||||||
|
chat_id: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.telegram) {
|
||||||
|
setConfig({ ...settings.telegram });
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (telegramConfig: TelegramConfig) =>
|
||||||
|
apiClient.updateNotificationSettings({
|
||||||
|
...settings,
|
||||||
|
telegram: telegramConfig,
|
||||||
|
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update Telegram configuration:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.testNotification({
|
||||||
|
service: "telegram",
|
||||||
|
message: "Test notification from Leggen - Telegram configuration is working!"
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
console.log("Test Telegram notification sent successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to send test Telegram notification:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateMutation.mutate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = () => {
|
||||||
|
testMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConfigValid = config.token.trim().length > 0 && config.chat_id !== 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{trigger || <EditButton />}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="flex items-center space-x-2">
|
||||||
|
<Send className="h-5 w-5 text-primary" />
|
||||||
|
<span>Telegram Configuration</span>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Configure Telegram bot notifications for transaction alerts
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-medium">Enable Telegram Notifications</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bot Token */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="telegram-token">Bot Token</Label>
|
||||||
|
<Input
|
||||||
|
id="telegram-token"
|
||||||
|
type="password"
|
||||||
|
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
value={config.token}
|
||||||
|
onChange={(e) => setConfig({ ...config, token: e.target.value })}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create a bot using @BotFather on Telegram to get your token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat ID */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="telegram-chat-id">Chat ID</Label>
|
||||||
|
<Input
|
||||||
|
id="telegram-chat-id"
|
||||||
|
type="number"
|
||||||
|
placeholder="123456789"
|
||||||
|
value={config.chat_id || ""}
|
||||||
|
onChange={(e) => setConfig({ ...config, chat_id: parseInt(e.target.value) || 0 })}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Send a message to your bot and visit https://api.telegram.org/bot<token>/getUpdates to find your chat ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Status */}
|
||||||
|
{config.enabled && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isConfigValid ? 'Configuration Valid' : 'Missing Token or Chat ID'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isConfigValid && (config.token.trim().length > 0 || config.chat_id !== 0) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Both bot token and chat ID are required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DrawerFooter className="px-0">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
||||||
|
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
||||||
|
</Button>
|
||||||
|
{config.enabled && isConfigValid && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2" />
|
||||||
|
Test
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ||
|
||||||
|
|||||||
116
frontend/src/components/ui/drawer.tsx
Normal file
116
frontend/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Drawer = ({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
|
<DrawerPrimitive.Root
|
||||||
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Drawer.displayName = "Drawer"
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
))
|
||||||
|
DrawerContent.displayName = "DrawerContent"
|
||||||
|
|
||||||
|
const DrawerHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerHeader.displayName = "DrawerHeader"
|
||||||
|
|
||||||
|
const DrawerFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerFooter.displayName = "DrawerFooter"
|
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
39
frontend/src/components/ui/edit-button.tsx
Normal file
39
frontend/src/components/ui/edit-button.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Edit3 } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface EditButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditButton({
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
size = "sm",
|
||||||
|
variant = "outline",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: EditButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
<span className="ml-2">{children || "Edit"}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/ui/scroll-area.tsx
Normal file
21
frontend/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||||
27
frontend/src/components/ui/switch.tsx
Normal file
27
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
frontend/src/hooks/useVersionCheck.ts
Normal file
40
frontend/src/hooks/useVersionCheck.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="space-y-8">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
|
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Time Period Filter */}
|
{/* Time Period Filter */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Account {
|
|||||||
name?: string;
|
name?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
logo?: string;
|
||||||
created: string;
|
created: string;
|
||||||
last_accessed?: string;
|
last_accessed?: string;
|
||||||
balances: AccountBalance[];
|
balances: AccountBalance[];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class AccountDetails(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
currency: Optional[str] = None
|
currency: Optional[str] = None
|
||||||
|
logo: Optional[str] = None
|
||||||
created: datetime
|
created: datetime
|
||||||
last_accessed: Optional[datetime] = None
|
last_accessed: Optional[datetime] = None
|
||||||
balances: List[AccountBalance] = []
|
balances: List[AccountBalance] = []
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ async def get_all_accounts() -> APIResponse:
|
|||||||
name=db_account.get("name"),
|
name=db_account.get("name"),
|
||||||
display_name=db_account.get("display_name"),
|
display_name=db_account.get("display_name"),
|
||||||
currency=db_account.get("currency"),
|
currency=db_account.get("currency"),
|
||||||
|
logo=db_account.get("logo"),
|
||||||
created=db_account["created"],
|
created=db_account["created"],
|
||||||
last_accessed=db_account.get("last_accessed"),
|
last_accessed=db_account.get("last_accessed"),
|
||||||
balances=balances,
|
balances=balances,
|
||||||
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
|
|||||||
name=db_account.get("name"),
|
name=db_account.get("name"),
|
||||||
display_name=db_account.get("display_name"),
|
display_name=db_account.get("display_name"),
|
||||||
currency=db_account.get("currency"),
|
currency=db_account.get("currency"),
|
||||||
|
logo=db_account.get("logo"),
|
||||||
created=db_account["created"],
|
created=db_account["created"],
|
||||||
last_accessed=db_account.get("last_accessed"),
|
last_accessed=db_account.get("last_accessed"),
|
||||||
balances=balances,
|
balances=balances,
|
||||||
|
|||||||
@@ -21,14 +21,15 @@ async def get_bank_institutions(
|
|||||||
) -> APIResponse:
|
) -> APIResponse:
|
||||||
"""Get available bank institutions for a country"""
|
"""Get available bank institutions for a country"""
|
||||||
try:
|
try:
|
||||||
institutions_data = await gocardless_service.get_institutions(country)
|
institutions_response = await gocardless_service.get_institutions(country)
|
||||||
|
institutions_data = institutions_response.get("results", [])
|
||||||
|
|
||||||
institutions = [
|
institutions = [
|
||||||
BankInstitution(
|
BankInstitution(
|
||||||
id=inst["id"],
|
id=inst["id"],
|
||||||
name=inst["name"],
|
name=inst["name"],
|
||||||
bic=inst.get("bic"),
|
bic=inst.get("bic"),
|
||||||
transaction_total_days=inst["transaction_total_days"],
|
transaction_total_days=int(inst["transaction_total_days"]),
|
||||||
countries=inst["countries"],
|
countries=inst["countries"],
|
||||||
logo=inst.get("logo"),
|
logo=inst.get("logo"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
@@ -102,17 +102,19 @@ 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
|
||||||
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 +147,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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ class DatabaseService:
|
|||||||
await self._migrate_to_composite_key_if_needed()
|
await self._migrate_to_composite_key_if_needed()
|
||||||
await self._migrate_add_display_name_if_needed()
|
await self._migrate_add_display_name_if_needed()
|
||||||
await self._migrate_add_sync_operations_if_needed()
|
await self._migrate_add_sync_operations_if_needed()
|
||||||
|
await self._migrate_add_logo_if_needed()
|
||||||
|
|
||||||
async def _migrate_balance_timestamps_if_needed(self):
|
async def _migrate_balance_timestamps_if_needed(self):
|
||||||
"""Check and migrate balance timestamps if needed"""
|
"""Check and migrate balance timestamps if needed"""
|
||||||
@@ -1133,7 +1134,8 @@ class DatabaseService:
|
|||||||
created DATETIME,
|
created DATETIME,
|
||||||
last_accessed DATETIME,
|
last_accessed DATETIME,
|
||||||
last_updated DATETIME,
|
last_updated DATETIME,
|
||||||
display_name TEXT
|
display_name TEXT,
|
||||||
|
logo TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1170,8 +1172,9 @@ class DatabaseService:
|
|||||||
created,
|
created,
|
||||||
last_accessed,
|
last_accessed,
|
||||||
last_updated,
|
last_updated,
|
||||||
display_name
|
display_name,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
logo
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
account_data["id"],
|
account_data["id"],
|
||||||
account_data["institution_id"],
|
account_data["institution_id"],
|
||||||
@@ -1183,6 +1186,7 @@ class DatabaseService:
|
|||||||
account_data.get("last_accessed"),
|
account_data.get("last_accessed"),
|
||||||
account_data.get("last_updated", account_data["created"]),
|
account_data.get("last_updated", account_data["created"]),
|
||||||
display_name,
|
display_name,
|
||||||
|
account_data.get("logo"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -1516,6 +1520,79 @@ class DatabaseService:
|
|||||||
logger.error(f"Sync operations table migration failed: {e}")
|
logger.error(f"Sync operations table migration failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _migrate_add_logo_if_needed(self):
|
||||||
|
"""Check and add logo column to accounts table if needed"""
|
||||||
|
try:
|
||||||
|
if await self._check_logo_migration_needed():
|
||||||
|
logger.info("Logo column migration needed, starting...")
|
||||||
|
await self._migrate_add_logo()
|
||||||
|
logger.info("Logo column migration completed")
|
||||||
|
else:
|
||||||
|
logger.info("Logo column already exists")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Logo column migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _check_logo_migration_needed(self) -> bool:
|
||||||
|
"""Check if logo column needs to be added to accounts table"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if accounts table exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if logo column exists
|
||||||
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
|
columns = cursor.fetchall()
|
||||||
|
|
||||||
|
# Check if logo column exists
|
||||||
|
has_logo = any(col[1] == "logo" for col in columns)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return not has_logo
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check logo migration status: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _migrate_add_logo(self):
|
||||||
|
"""Add logo column to accounts table"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.warning("Database file not found, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
logger.info("Adding logo column to accounts table...")
|
||||||
|
|
||||||
|
# Add the logo column
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN logo TEXT
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("Logo column migration completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Logo column migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
|
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
|
||||||
"""Persist sync operation to database and return the ID"""
|
"""Persist sync operation to database and return the ID"""
|
||||||
if not self.sqlite_enabled:
|
if not self.sqlite_enabled:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,14 +11,13 @@ from leggen.utils.paths import path_manager
|
|||||||
|
|
||||||
def _log_rate_limits(response):
|
def _log_rate_limits(response):
|
||||||
"""Log GoCardless API rate limit headers"""
|
"""Log GoCardless API rate limit headers"""
|
||||||
limit = response.headers.get("X-RateLimit-Limit")
|
limit = response.headers.get("http_x_ratelimit_limit")
|
||||||
remaining = response.headers.get("X-RateLimit-Remaining")
|
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||||
reset = response.headers.get("X-RateLimit-Reset")
|
reset = response.headers.get("http_x_ratelimit_reset")
|
||||||
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
|
|
||||||
|
|
||||||
if limit or remaining or reset or account_success_reset:
|
if limit or remaining or reset:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s, Account Success Reset: {account_success_reset}"
|
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +29,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 +122,48 @@ 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
|
|
||||||
)
|
async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
|
||||||
_log_rate_limits(response)
|
"""Get institution details by ID"""
|
||||||
response.raise_for_status()
|
return await self._make_authenticated_request(
|
||||||
return response.json()
|
"GET", f"{self.base_url}/institutions/{institution_id}/"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SyncService:
|
|||||||
self.database = DatabaseService()
|
self.database = DatabaseService()
|
||||||
self.notifications = NotificationService()
|
self.notifications = NotificationService()
|
||||||
self._sync_status = SyncStatus(is_running=False)
|
self._sync_status = SyncStatus(is_running=False)
|
||||||
|
self._institution_logos = {} # Cache for institution logos
|
||||||
|
|
||||||
async def get_sync_status(self) -> SyncStatus:
|
async def get_sync_status(self) -> SyncStatus:
|
||||||
"""Get current sync status"""
|
"""Get current sync status"""
|
||||||
@@ -77,7 +78,7 @@ class SyncService:
|
|||||||
# Get balances to extract currency information
|
# Get balances to extract currency information
|
||||||
balances = await self.gocardless.get_account_balances(account_id)
|
balances = await self.gocardless.get_account_balances(account_id)
|
||||||
|
|
||||||
# Enrich account details with currency and persist
|
# Enrich account details with currency and institution logo
|
||||||
if account_details and balances:
|
if account_details and balances:
|
||||||
enriched_account_details = account_details.copy()
|
enriched_account_details = account_details.copy()
|
||||||
|
|
||||||
@@ -90,6 +91,26 @@ class SyncService:
|
|||||||
if currency:
|
if currency:
|
||||||
enriched_account_details["currency"] = currency
|
enriched_account_details["currency"] = currency
|
||||||
|
|
||||||
|
# Get institution details to fetch logo
|
||||||
|
institution_id = enriched_account_details.get("institution_id")
|
||||||
|
if institution_id:
|
||||||
|
try:
|
||||||
|
institution_details = (
|
||||||
|
await self.gocardless.get_institution_details(
|
||||||
|
institution_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
enriched_account_details["logo"] = (
|
||||||
|
institution_details.get("logo", "")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to fetch institution details for {institution_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Persist enriched account details to database
|
# Persist enriched account details to database
|
||||||
await self.database.persist_account_details(
|
await self.database.persist_account_details(
|
||||||
enriched_account_details
|
enriched_account_details
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.20"
|
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"
|
||||||
|
|||||||
@@ -103,22 +103,24 @@ def mock_db_path(temp_db_path):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_bank_data():
|
def sample_bank_data():
|
||||||
"""Sample bank/institution data for testing."""
|
"""Sample bank/institution data for testing."""
|
||||||
return [
|
return {
|
||||||
{
|
"results": [
|
||||||
"id": "REVOLUT_REVOLT21",
|
{
|
||||||
"name": "Revolut",
|
"id": "REVOLUT_REVOLT21",
|
||||||
"bic": "REVOLT21",
|
"name": "Revolut",
|
||||||
"transaction_total_days": 90,
|
"bic": "REVOLT21",
|
||||||
"countries": ["GB", "LT"],
|
"transaction_total_days": 90,
|
||||||
},
|
"countries": ["GB", "LT"],
|
||||||
{
|
},
|
||||||
"id": "BANCOBPI_BBPIPTPL",
|
{
|
||||||
"name": "Banco BPI",
|
"id": "BANCOBPI_BBPIPTPL",
|
||||||
"bic": "BBPIPTPL",
|
"name": "Banco BPI",
|
||||||
"transaction_total_days": 90,
|
"bic": "BBPIPTPL",
|
||||||
"countries": ["PT"],
|
"transaction_total_days": 90,
|
||||||
},
|
"countries": ["PT"],
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class TestBanksAPI:
|
|||||||
|
|
||||||
# Mock empty institutions response for invalid country
|
# Mock empty institutions response for invalid country
|
||||||
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
||||||
return_value=httpx.Response(200, json=[])
|
return_value=httpx.Response(200, json={"results": []})
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -37,9 +40,12 @@ class TestLeggenAPIClient:
|
|||||||
"""Test getting institutions via API client."""
|
"""Test getting institutions via API client."""
|
||||||
client = LeggenAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
# The API returns processed institutions, not raw GoCardless data
|
||||||
|
processed_institutions = sample_bank_data["results"]
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": sample_bank_data,
|
"data": processed_institutions,
|
||||||
"message": "Found 2 institutions for PT",
|
"message": "Found 2 institutions for PT",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,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
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.20"
|
version = "2025.9.23"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user