feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink
#1 Sync interval in admin UI: - bankSyncConfigService: reads simplefin_sync_interval_hours from settings (DB-first, env fallback, default 4h), setSyncIntervalHours() with validation - bankSyncWorker: live-updates interval from getBankSyncConfig() each tick - routes/admin: PUT accepts enabled and sync_interval_hours independently - BankSyncAdminCard: number input (0.5 step, 0.5-168 range), dirty-checks both #3 Auto-match after background sync: - matchSuggestionService: autoMatchForUser() auto-applies suggestions ≥80 score (exact amount + date ±1d + name signal), lazy-requires matchTransactionToBill - bankSyncWorker: calls autoMatchForUser after each successful sync, own try/catch #4 Encryption note in BankSyncAdminCard below worker status panel Also: error handling, admin link in tracker sidebar, SimpleFIN bridge hyperlink
This commit is contained in:
parent
b03264ceb1
commit
542ab5e382
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Toggle } from './adminShared';
|
||||
|
||||
|
|
@ -26,26 +27,35 @@ function timeUntil(iso) {
|
|||
export default function BankSyncAdminCard() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [syncInterval, setSyncInterval] = useState(4);
|
||||
|
||||
useEffect(() => {
|
||||
api.bankSyncConfig()
|
||||
.then(d => {
|
||||
setConfig(d);
|
||||
setEnabled(!!d.enabled);
|
||||
setSyncInterval(d.sync_interval_hours ?? 4);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(err => setLoadError(err.message || 'Failed to load bank sync config'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const hours = parseFloat(syncInterval);
|
||||
if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) {
|
||||
toast.error('Sync interval must be between 0.5 and 168 hours.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.setBankSyncConfig({ enabled });
|
||||
const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours });
|
||||
setConfig(result);
|
||||
setEnabled(!!result.enabled);
|
||||
toast.success(enabled ? 'Bank sync enabled.' : 'Bank sync disabled.');
|
||||
setSyncInterval(result.sync_interval_hours ?? 4);
|
||||
toast.success('Bank sync settings saved.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update bank sync setting.');
|
||||
} finally {
|
||||
|
|
@ -63,7 +73,17 @@ export default function BankSyncAdminCard() {
|
|||
);
|
||||
}
|
||||
|
||||
const changed = enabled !== !!config?.enabled;
|
||||
if (loadError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-destructive">
|
||||
{loadError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const changed = enabled !== !!config?.enabled || parseFloat(syncInterval) !== config?.sync_interval_hours;
|
||||
const worker = config?.worker;
|
||||
|
||||
return (
|
||||
|
|
@ -93,6 +113,28 @@ export default function BankSyncAdminCard() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync interval */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Auto-sync interval</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
How often the background worker checks for new transactions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="168"
|
||||
step="0.5"
|
||||
value={syncInterval}
|
||||
onChange={e => setSyncInterval(e.target.value)}
|
||||
className="w-20 text-sm text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync worker status */}
|
||||
{config?.enabled && worker && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-3 space-y-2">
|
||||
|
|
@ -114,13 +156,16 @@ export default function BankSyncAdminCard() {
|
|||
<p className="font-medium">{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Syncs every {worker.interval_hours}h. Set <code className="font-mono">SIMPLEFIN_SYNC_INTERVAL_HOURS</code> to adjust.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
{/* Encryption note */}
|
||||
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
|
||||
SimpleFIN credentials are encrypted with a key stored in your database.
|
||||
Regular database backups preserve all user connections.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving || !changed}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function EmailNotifCard() {
|
|||
|
||||
const [cfg, setCfg] = useState(DEFAULTS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
|
|
@ -31,7 +32,7 @@ export default function EmailNotifCard() {
|
|||
useEffect(() => {
|
||||
api.notifAdmin()
|
||||
.then(d => setCfg({ ...DEFAULTS, ...d }))
|
||||
.catch(() => {})
|
||||
.catch(err => setLoadError(err.message || 'Failed to load email settings'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export default function EmailNotifCard() {
|
|||
};
|
||||
|
||||
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
||||
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
export default function LoginModeCard({ users }) {
|
||||
const [modeData, setModeData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ export default function LoginModeCard({ users }) {
|
|||
useEffect(() => {
|
||||
api.authModeConfig()
|
||||
.then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); })
|
||||
.catch(() => {})
|
||||
.catch(err => setLoadError(err.message || 'Failed to load login mode config'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ export default function LoginModeCard({ users }) {
|
|||
};
|
||||
|
||||
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
||||
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
|
||||
|
||||
const isMulti = !modeData || modeData.auth_mode === 'multi';
|
||||
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { AlertTriangle, Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -125,6 +125,12 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function isStale(conn) {
|
||||
if (!conn.last_error) return false;
|
||||
if (!conn.last_sync_at) return true;
|
||||
return Date.now() - new Date(conn.last_sync_at).getTime() > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
if (enabled === null) {
|
||||
return (
|
||||
<SectionCard title="Bank Sync">
|
||||
|
|
@ -151,6 +157,30 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
|
||||
{connections.length > 0 && connections.map(conn => (
|
||||
<div key={conn.id} className="px-6 py-4 space-y-3">
|
||||
{isStale(conn) && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">Sync error</span>
|
||||
{conn.last_error && (
|
||||
<span className="ml-1 font-normal opacity-90">— {conn.last_error}</span>
|
||||
)}
|
||||
{conn.last_sync_at && (
|
||||
<span className="block text-xs mt-0.5 opacity-75">
|
||||
Last successful sync: {fmtDate(conn.last_sync_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSync(conn.id)}
|
||||
disabled={syncing === conn.id}
|
||||
className="shrink-0 text-xs font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{syncing === conn.id ? 'Syncing…' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const userNavItems = [
|
|||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export default function AdminPage() {
|
|||
|
||||
const [me, setMe] = useState(null);
|
||||
const [hasUsers, setHasUsers] = useState(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const loadMe = useCallback(async () => {
|
||||
|
|
@ -40,7 +41,10 @@ export default function AdminPage() {
|
|||
const d = await api.hasUsers();
|
||||
setHasUsers(d.has_users);
|
||||
if (d.has_users) loadUsers();
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
setLoadError(err.message || 'Failed to load admin page');
|
||||
setHasUsers(false);
|
||||
}
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -53,7 +57,7 @@ export default function AdminPage() {
|
|||
loadUsers();
|
||||
};
|
||||
|
||||
if (hasUsers === null) {
|
||||
if (hasUsers === null && !loadError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen text-muted-foreground text-sm">
|
||||
Loading…
|
||||
|
|
@ -61,6 +65,14 @@ export default function AdminPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen text-destructive text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] text-foreground">
|
||||
<AppNavigation adminMode />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.31.0",
|
||||
"version": "0.32.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb, rollbackMigration } = require('../db/database');
|
||||
const { getBankSyncConfig, setBankSyncEnabled } = require('../services/bankSyncConfigService');
|
||||
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours } = require('../services/bankSyncConfigService');
|
||||
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
|
||||
const { hashPassword } = require('../services/authService');
|
||||
const { logAudit } = require('../services/auditService');
|
||||
|
|
@ -407,12 +407,16 @@ router.get('/bank-sync-config', (req, res) => {
|
|||
|
||||
// PUT /api/admin/bank-sync-config
|
||||
router.put('/bank-sync-config', (req, res) => {
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
}
|
||||
const { enabled, sync_interval_hours } = req.body || {};
|
||||
try {
|
||||
res.json(setBankSyncEnabled(enabled));
|
||||
let config = getBankSyncConfig();
|
||||
if (typeof enabled === 'boolean') {
|
||||
config = setBankSyncEnabled(enabled);
|
||||
}
|
||||
if (sync_interval_hours !== undefined) {
|
||||
config = setSyncIntervalHours(sync_interval_hours);
|
||||
}
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
const { getSetting, setSetting } = require('../db/database');
|
||||
|
||||
const SYNC_DAYS_DEFAULT = 90;
|
||||
const SYNC_INTERVAL_DEFAULT = 4; // hours
|
||||
|
||||
function getBankSyncConfig() {
|
||||
const dbValue = getSetting('bank_sync_enabled');
|
||||
|
|
@ -25,9 +26,18 @@ function getBankSyncConfig() {
|
|||
? syncDaysEnv
|
||||
: SYNC_DAYS_DEFAULT;
|
||||
|
||||
const intervalDb = parseFloat(getSetting('simplefin_sync_interval_hours') || '');
|
||||
const intervalEnv = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS || '');
|
||||
const syncIntervalHours = Number.isFinite(intervalDb) && intervalDb >= 0.5
|
||||
? intervalDb
|
||||
: Number.isFinite(intervalEnv) && intervalEnv >= 0.5
|
||||
? intervalEnv
|
||||
: SYNC_INTERVAL_DEFAULT;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sync_days: syncDays,
|
||||
sync_interval_hours: syncIntervalHours,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -36,4 +46,13 @@ function setBankSyncEnabled(enabled) {
|
|||
return getBankSyncConfig();
|
||||
}
|
||||
|
||||
module.exports = { getBankSyncConfig, setBankSyncEnabled };
|
||||
function setSyncIntervalHours(hours) {
|
||||
const n = parseFloat(hours);
|
||||
if (!Number.isFinite(n) || n < 0.5 || n > 168) {
|
||||
throw Object.assign(new Error('sync_interval_hours must be between 0.5 and 168'), { status: 400 });
|
||||
}
|
||||
setSetting('simplefin_sync_interval_hours', String(Math.round(n * 10) / 10));
|
||||
return getBankSyncConfig();
|
||||
}
|
||||
|
||||
module.exports = { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours };
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
const { getDb } = require('../db/database');
|
||||
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||
const { syncDataSource } = require('./bankSyncService');
|
||||
const { autoMatchForUser } = require('./matchSuggestionService');
|
||||
|
||||
const DEFAULT_INTERVAL_HOURS = 4;
|
||||
// Skip a source if it was synced less than this long ago (catches recent manual syncs)
|
||||
const MIN_SYNC_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
// Pause between each source to avoid hammering SimpleFIN
|
||||
|
|
@ -16,10 +16,8 @@ let lastRunAt = null;
|
|||
let nextRunAt = null;
|
||||
|
||||
function intervalMs() {
|
||||
const hours = parseFloat(process.env.SIMPLEFIN_SYNC_INTERVAL_HOURS);
|
||||
return Number.isFinite(hours) && hours >= 0.5
|
||||
? Math.round(hours * 3600000)
|
||||
: DEFAULT_INTERVAL_HOURS * 3600000;
|
||||
const { sync_interval_hours } = getBankSyncConfig();
|
||||
return Math.round(sync_interval_hours * 3600000);
|
||||
}
|
||||
|
||||
function needsSync(source) {
|
||||
|
|
@ -71,6 +69,7 @@ async function runCycle() {
|
|||
try {
|
||||
await syncDataSource(db, source.user_id, source.id);
|
||||
synced++;
|
||||
try { autoMatchForUser(source.user_id); } catch { /* non-fatal */ }
|
||||
} catch {
|
||||
// syncDataSource already writes last_error to the data_sources row
|
||||
failed++;
|
||||
|
|
@ -107,8 +106,7 @@ function scheduleNext() {
|
|||
function start() {
|
||||
if (timer) return;
|
||||
scheduleNext();
|
||||
const hours = intervalMs() / 3600000;
|
||||
console.log(`[bankSync] Auto-sync worker started (interval: ${hours}h)`);
|
||||
console.log(`[bankSync] Auto-sync worker started (interval: ${getBankSyncConfig().sync_interval_hours}h)`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
|
|
@ -119,7 +117,7 @@ function stop() {
|
|||
function getStatus() {
|
||||
return {
|
||||
running,
|
||||
interval_hours: intervalMs() / 3600000,
|
||||
interval_hours: getBankSyncConfig().sync_interval_hours,
|
||||
last_run_at: lastRunAt,
|
||||
next_run_at: nextRunAt,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -326,7 +326,28 @@ function rejectMatchSuggestion(userId, id) {
|
|||
};
|
||||
}
|
||||
|
||||
// Auto-apply high-confidence suggestions (score >= 80) for a user.
|
||||
// Called by the background sync worker after each successful source sync.
|
||||
// Score of 80+ requires at minimum exact amount + date proximity + name signal,
|
||||
// so false-positive risk is low.
|
||||
function autoMatchForUser(userId) {
|
||||
const { matchTransactionToBill } = require('./transactionMatchService');
|
||||
const suggestions = listMatchSuggestions(userId, { limit: 50 });
|
||||
let matched = 0;
|
||||
for (const s of suggestions) {
|
||||
if (s.score < 80) break; // sorted descending — safe to stop early
|
||||
try {
|
||||
matchTransactionToBill(userId, s.transactionId, s.billId);
|
||||
matched++;
|
||||
} catch {
|
||||
// Already matched, ignored, bill deleted, or date missing — skip silently
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
autoMatchForUser,
|
||||
listMatchSuggestions,
|
||||
parseSuggestionId,
|
||||
rejectMatchSuggestion,
|
||||
|
|
|
|||
Loading…
Reference in New Issue