corrections
This commit is contained in:
parent
d2acf44846
commit
48fe87ea25
|
|
@ -1450,44 +1450,30 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
function SeedDemoDataSection({ onSeeded }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
|
||||
// Check seeded status on mount
|
||||
useEffect(() => {
|
||||
const checkSeededStatus = async () => {
|
||||
try {
|
||||
const data = await api.seededStatus();
|
||||
if (data.seeded) {
|
||||
setSeeded(true);
|
||||
setResult(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check seeded status:', err);
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
checkSeededStatus();
|
||||
api.seededStatus()
|
||||
.then(data => {
|
||||
setSeeded(data.seeded);
|
||||
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
||||
})
|
||||
.catch(err => console.error('Failed to check seeded status:', err))
|
||||
.finally(() => setStatusLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSeed = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.seedDemoData();
|
||||
// Ensure data has expected structure
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
setResult(data);
|
||||
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
||||
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
||||
setSeeded(true);
|
||||
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
||||
// Delay onSeeded callback to allow UI to update
|
||||
setTimeout(() => {
|
||||
onSeeded?.();
|
||||
}, 100);
|
||||
setTimeout(() => onSeeded?.(), 100);
|
||||
} catch (err) {
|
||||
console.error('Seed error:', err);
|
||||
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
||||
|
|
@ -1501,87 +1487,69 @@ function SeedDemoDataSection({ onSeeded }) {
|
|||
try {
|
||||
const data = await api.clearDemoData();
|
||||
setSeeded(false);
|
||||
setResult(null);
|
||||
setCounts({ bills: 0, categories: 0 });
|
||||
setShowClearConfirm(false);
|
||||
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
||||
onSeeded?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || "Failed to clear demo data.");
|
||||
toast.error(err.message || 'Failed to clear demo data.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (seeded) {
|
||||
return (
|
||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Seed complete</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bills Created</p>
|
||||
<p className="font-semibold">{result?.billsCreated || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Categories Created</p>
|
||||
<p className="font-semibold">{result?.categoriesCreated || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => { setSeeded(false); setResult(null); }}>
|
||||
Reset
|
||||
</Button>
|
||||
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive" disabled={clearing}>
|
||||
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action will remove {result?.billsCreated || 0} demo bills and {result?.categoriesCreated || 0} demo categories from your account. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Data'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||
<div className="px-6 py-4 text-sm text-muted-foreground">Loading…</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
||||
The data will be associated with your account.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="border-t border-border pt-4">
|
||||
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded}>
|
||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
||||
</Button>
|
||||
</div>
|
||||
{statusLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : seeded ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bills</p>
|
||||
<p className="font-semibold">{counts.bills}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Categories</p>
|
||||
<p className="font-semibold">{counts.categories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
||||
The data will be associated with your account.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}>
|
||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive" disabled={!seeded || clearing || statusLoading}>
|
||||
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Data'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
|
||||
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -156,12 +155,6 @@ export default function LoginPage() {
|
|||
className="w-full"
|
||||
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
||||
>
|
||||
<img
|
||||
src={AUTHENTIK_ICON_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-5 w-5 shrink-0 object-contain"
|
||||
/>
|
||||
Continue with {providerName}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -853,18 +853,34 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
className={cn(
|
||||
'font-medium text-sm leading-tight text-left transition-colors',
|
||||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||||
isSkipped && 'line-through',
|
||||
<div className="flex items-center gap-1">
|
||||
{row.website ? (
|
||||
<a
|
||||
href={row.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'font-medium text-sm leading-tight transition-colors',
|
||||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
|
||||
{row.name}
|
||||
</span>
|
||||
)}
|
||||
title="Edit bill"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit bill"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{row.category_name && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
||||
)}
|
||||
|
|
@ -961,27 +977,6 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit payment (pencil) */}
|
||||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Monthly state editor (gear icon) — always available */}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
AP
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
{row.website ? (
|
||||
<a
|
||||
href={row.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
||||
{row.name}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit bill"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
|
|
@ -1164,27 +1173,6 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Payment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Month
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -605,9 +605,73 @@ function reconcileLegacyMigrations() {
|
|||
console.log('[migration] sessions.created_at column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.44',
|
||||
description: 'performance: add missing indexes for frequently queried columns',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.45',
|
||||
description: 'audit: add audit_log table for security event tracking',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id INTEGER,
|
||||
details_json TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.46',
|
||||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return cols.includes('cycle_type') && cols.includes('cycle_day');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('cycle_type')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||
}
|
||||
if (!cols.includes('cycle_day')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.47',
|
||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||
check: function() {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
|
||||
return !row || row.value !== '14';
|
||||
},
|
||||
run: function() {
|
||||
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
||||
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// Check for legacy notification columns
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const newUserCols = [
|
||||
|
|
@ -1071,14 +1135,26 @@ function runMigrations() {
|
|||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||
dependsOn: ['v0.45'],
|
||||
run: function() {
|
||||
// Add cycle_type column (default 'monthly' for existing bills)
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||
// Add cycle_day column for specific day within the cycle
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('cycle_type')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||
}
|
||||
if (!cols.includes('cycle_day')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.47',
|
||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||
dependsOn: ['v0.46'],
|
||||
run: function() {
|
||||
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
||||
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// ── users: notification columns ───────────────────────────────────────────
|
||||
// This migration needs to run first since it's not versioned in the schema
|
||||
console.log('[migration] Applying unversioned user notification columns');
|
||||
|
|
@ -1314,7 +1390,7 @@ function seedDefaults() {
|
|||
['backup_schedule_enabled', 'false'],
|
||||
['backup_schedule_frequency', 'daily'],
|
||||
['backup_schedule_time', '02:00'],
|
||||
['backup_schedule_retention_count', '14'],
|
||||
['backup_schedule_retention_count', '2'],
|
||||
['backup_schedule_last_run_at', ''],
|
||||
['backup_schedule_last_error', ''],
|
||||
['auth_mode', 'multi'],
|
||||
|
|
@ -1439,6 +1515,12 @@ const ROLLBACK_SQL_MAP = {
|
|||
'ALTER TABLE bills DROP COLUMN cycle_day',
|
||||
'ALTER TABLE bills DROP COLUMN cycle_type'
|
||||
]
|
||||
},
|
||||
'v0.47': {
|
||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||
sql: [
|
||||
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,47 @@ router.put('/users/:id/active', (req, res) => {
|
|||
).get(targetId));
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/username
|
||||
router.put('/users/:id/username', (req, res) => {
|
||||
const { username } = req.body;
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username is required' });
|
||||
}
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 3) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
}
|
||||
if (trimmed.length > 50) {
|
||||
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
|
||||
}
|
||||
|
||||
const targetId = Number(req.params.id);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const taken = db.prepare(
|
||||
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
||||
).get(trimmed, targetId);
|
||||
if (taken) return res.status(409).json({ error: 'Username already taken' });
|
||||
|
||||
const previousUsername = user.username;
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(trimmed, targetId);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id, action: 'admin.username.change',
|
||||
entity_type: 'user', entity_id: targetId,
|
||||
details: { old_username: previousUsername, new_username: trimmed },
|
||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.json(
|
||||
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
|
||||
.get(targetId)
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id
|
||||
router.delete('/users/:id', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -57,10 +57,34 @@ router.get('/', (req, res) => {
|
|||
});
|
||||
|
||||
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
||||
// Updates safe profile fields: display_name only.
|
||||
// Updates safe profile fields: username and display_name.
|
||||
// Ignores any unknown or restricted fields.
|
||||
router.patch('/', (req, res) => {
|
||||
const { display_name } = req.body;
|
||||
const { username, display_name } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (username !== undefined) {
|
||||
if (typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username must be a string' });
|
||||
}
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < 3) {
|
||||
return res.status(400).json({ error: 'username must be at least 3 characters' });
|
||||
}
|
||||
if (trimmedUsername.length > 50) {
|
||||
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
|
||||
}
|
||||
const taken = db.prepare(
|
||||
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
||||
).get(trimmedUsername, req.user.id);
|
||||
if (taken) {
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(trimmedUsername, req.user.id);
|
||||
logAudit({ user_id: req.user.id, action: 'profile.username.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||
}
|
||||
|
||||
if (display_name !== undefined) {
|
||||
if (typeof display_name !== 'string') {
|
||||
|
|
@ -71,7 +95,7 @@ router.patch('/', (req, res) => {
|
|||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||
}
|
||||
|
||||
getDb().prepare(
|
||||
db.prepare(
|
||||
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(trimmed || null, req.user.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function validateScheduleSettings(input = {}) {
|
|||
const enabled = parseBool(input.enabled);
|
||||
const frequency = input.frequency || 'daily';
|
||||
const time = input.time || '02:00';
|
||||
const retentionCount = parseInt(input.retention_count ?? '14', 10);
|
||||
const retentionCount = parseInt(input.retention_count ?? '2', 10);
|
||||
|
||||
if (!['daily', 'weekly'].includes(frequency)) {
|
||||
const err = new Error('frequency must be daily or weekly');
|
||||
|
|
@ -47,7 +47,7 @@ function readSettings() {
|
|||
enabled: getSetting('backup_schedule_enabled') === 'true',
|
||||
frequency: getSetting('backup_schedule_frequency') || 'daily',
|
||||
time: getSetting('backup_schedule_time') || '02:00',
|
||||
retention_count: getSetting('backup_schedule_retention_count') || '14',
|
||||
retention_count: getSetting('backup_schedule_retention_count') || '2',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const crypto = require('crypto');
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { closeDb, getDb, getDbPath } = require('../db/database');
|
||||
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database');
|
||||
|
||||
const BACKUP_DIR = path.resolve(
|
||||
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
||||
|
|
@ -166,7 +166,10 @@ async function createBackup(prefix = 'bill-tracker-backup') {
|
|||
validateSqliteDatabase(tempPath);
|
||||
fs.renameSync(tempPath, finalPath);
|
||||
fs.chmodSync(finalPath, 0o600);
|
||||
return metadataForFile(finalPath);
|
||||
const meta = metadataForFile(finalPath);
|
||||
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
|
||||
applyRetention(retentionCount);
|
||||
return meta;
|
||||
} catch (err) {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||
cleanupSqliteSidecars(tempPath);
|
||||
|
|
@ -239,25 +242,28 @@ function deleteBackup(id) {
|
|||
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function applyScheduledRetention(retentionCount) {
|
||||
function applyRetention(retentionCount) {
|
||||
const keep = parseInt(retentionCount, 10);
|
||||
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
||||
|
||||
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
|
||||
const toDelete = scheduled.slice(keep);
|
||||
// listBackups() is already sorted newest-first; delete everything beyond `keep`
|
||||
const toDelete = listBackups().slice(keep);
|
||||
const deleted = [];
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
deleted.push(deleteBackup(backup.id).id);
|
||||
} catch {
|
||||
// Retention should never make a scheduled backup fail.
|
||||
// Retention should never cause a backup operation to fail.
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
// Keep old name as an alias so the scheduler import still works.
|
||||
const applyScheduledRetention = applyRetention;
|
||||
|
||||
async function restoreBackup(id) {
|
||||
const source = getBackupFile(id);
|
||||
validateSqliteDatabase(source.path);
|
||||
|
|
@ -299,6 +305,7 @@ async function restoreBackup(id) {
|
|||
module.exports = {
|
||||
BACKUP_DIR,
|
||||
assertValidBackupId,
|
||||
applyRetention,
|
||||
applyScheduledRetention,
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const HEADER_PATTERNS = {
|
|||
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
|
||||
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i,
|
||||
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date|date\s*cleared|cleared\s*date)$/i,
|
||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date)$/i,
|
||||
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
|
||||
category: /^(?:category|cat|type|group)$/i,
|
||||
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
|
||||
|
|
@ -233,8 +233,13 @@ function parseXlsxBuffer(buffer) {
|
|||
function getSheetRows(workbook, sheetName) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) return [];
|
||||
// raw:false → formatted string values; no formula results can leak through
|
||||
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
||||
try {
|
||||
// raw:false → formatted string values; no formula results can leak through
|
||||
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
||||
} catch (err) {
|
||||
console.error(`[import] sheet="${sheetName}" failed to parse rows — skipping:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header Detection ─────────────────────────────────────────────────────────
|
||||
|
|
@ -617,6 +622,7 @@ function buildRecommendation({
|
|||
billName,
|
||||
detectedAmount,
|
||||
parsedDate,
|
||||
parsedPaidDate,
|
||||
dateHeader,
|
||||
detectedCategory,
|
||||
notesText,
|
||||
|
|
@ -634,7 +640,11 @@ function buildRecommendation({
|
|||
|
||||
const dateDay = parsedDate?.day;
|
||||
let dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
||||
// Use defaultDueDay from header set if date parsing didn't find a day
|
||||
// Fall back to the paid-date column's day (e.g. column D), then to defaultDueDay
|
||||
if (dueDay === null) {
|
||||
const paidDay = parsedPaidDate?.day;
|
||||
if (Number.isInteger(paidDay) && paidDay >= 1 && paidDay <= 31) dueDay = paidDay;
|
||||
}
|
||||
if (dueDay === null && defaultDueDay !== null) {
|
||||
dueDay = defaultDueDay;
|
||||
}
|
||||
|
|
@ -839,11 +849,32 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
|||
if (!billName) errors.push('No bill name detected');
|
||||
if (detectedAmount === null) warnings.push('No amount detected');
|
||||
|
||||
// ── Diagnostic logging for auto-detected patterns ──────────────────────────
|
||||
const _rawDue = get('due_date') != null ? String(get('due_date')).trim() : '';
|
||||
const _rawPaid = get('paid_date') != null ? String(get('paid_date')).trim() : '';
|
||||
const _loc = `sheet="${sheetName}" row=${rowIndex + 1}${billName ? ` bill="${billName}"` : ''}`;
|
||||
|
||||
if (detectedLabels.includes('autopay') && billName) {
|
||||
if (_rawDue && /auto/i.test(_rawDue) && /\d/.test(_rawDue)) {
|
||||
console.log(`[import] ${_loc} autopay+date in due col: "${_rawDue}" (date portion not extracted)`);
|
||||
} else {
|
||||
console.log(`[import] ${_loc} autopay detected`);
|
||||
}
|
||||
}
|
||||
if (detectedLabels.includes('past_due')) {
|
||||
console.log(`[import] ${_loc} PAST DUE detected`);
|
||||
}
|
||||
if (_rawPaid && !parsedPaidDate) {
|
||||
console.log(`[import] ${_loc} unparseable paid date: "${_rawPaid}"`);
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
|
||||
const recommendation = buildRecommendation({
|
||||
billName,
|
||||
detectedAmount,
|
||||
parsedDate,
|
||||
parsedPaidDate,
|
||||
dateHeader,
|
||||
detectedCategory,
|
||||
notesText,
|
||||
|
|
@ -938,6 +969,17 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row
|
|||
const hasHeaders = hasValidHeaders;
|
||||
const startRow = hasHeaders ? headerRowIndex + 1 : 0;
|
||||
|
||||
// Log detected layout for this sheet
|
||||
const _colLetter = (i) => String.fromCharCode(65 + i);
|
||||
if (!hasHeaders) {
|
||||
console.log(`[import] sheet="${name}" no valid headers detected — sheet will be skipped`);
|
||||
} else {
|
||||
for (const [si, set] of allHeaderSets.entries()) {
|
||||
const mapped = Object.entries(set.map).map(([f, i]) => `${f}:${_colLetter(i)}`).join(', ');
|
||||
console.log(`[import] sheet="${name}" group=${si} defaultDueDay=${set.defaultDueDay} columns={${mapped}}`);
|
||||
}
|
||||
}
|
||||
|
||||
// For dual-column layouts, collect ALL column indices across all header sets
|
||||
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
|
||||
// accidentally pick up values from the other column set.
|
||||
|
|
@ -999,13 +1041,17 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row
|
|||
const isLeftoverCalcRow = !billName && amount !== null && amount < 0;
|
||||
|
||||
if (isDashSeparator || isLeftoverCalcRow) continue;
|
||||
|
||||
rows.push(analyzeRow(
|
||||
i, cells, headerMap, headerLabels, userBills, categories,
|
||||
name, sheetYear, sheetMonth,
|
||||
defaultYear, defaultMonth, rowIdPrefix,
|
||||
defaultDueDay, setIdx, allColumnsIndices,
|
||||
));
|
||||
|
||||
try {
|
||||
rows.push(analyzeRow(
|
||||
i, cells, headerMap, headerLabels, userBills, categories,
|
||||
name, sheetYear, sheetMonth,
|
||||
defaultYear, defaultMonth, rowIdPrefix,
|
||||
defaultDueDay, setIdx, allColumnsIndices,
|
||||
));
|
||||
} catch (err) {
|
||||
console.error(`[import] sheet="${name}" row=${i + 1} failed to analyze — skipping:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1061,7 +1107,9 @@ function pruneExpiredSessions(db) {
|
|||
|
||||
async function previewSpreadsheet(userId, buffer, options = {}) {
|
||||
const db = getDb();
|
||||
pruneExpiredSessions(db);
|
||||
try { pruneExpiredSessions(db); } catch (err) {
|
||||
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
|
||||
}
|
||||
ensureUserDefaultCategories(userId);
|
||||
|
||||
const workbook = parseXlsxBuffer(buffer);
|
||||
|
|
@ -1457,11 +1505,12 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
|||
|
||||
const dueDay = decision.due_day ?? 1;
|
||||
const expectedAmount = decision.expected_amount ?? amount ?? 0;
|
||||
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
|
||||
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
|
||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
|
||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
|
||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
|
||||
|
||||
const newBillId = ins.lastInsertRowid;
|
||||
summary.created++;
|
||||
|
|
@ -1500,9 +1549,14 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
|||
|
||||
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
|
||||
const billId = decision.bill_id;
|
||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||
const bill = db.prepare('SELECT id, name, autopay_enabled FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
||||
|
||||
if (!bill.autopay_enabled && previewRow?.detected_labels?.includes('autopay')) {
|
||||
db.prepare(`UPDATE bills SET autopay_enabled = 1, updated_at = datetime('now') WHERE id = ?`).run(billId);
|
||||
console.log(`[import] bill id=${billId} "${bill.name}" autopay_enabled upgraded to 1`);
|
||||
}
|
||||
|
||||
if (!year || !month) {
|
||||
summary.ambiguous++;
|
||||
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
|
||||
|
|
|
|||
Loading…
Reference in New Issue