diff --git a/client/api.js b/client/api.js index 091eb17..3ff5e0b 100644 --- a/client/api.js +++ b/client/api.js @@ -210,6 +210,7 @@ export const api = { // Bills bills: (params = {}) => get(`/bills${queryString(params)}`), allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), diff --git a/client/components/RecentlyDeletedBillsDialog.jsx b/client/components/RecentlyDeletedBillsDialog.jsx new file mode 100644 index 0000000..991342d --- /dev/null +++ b/client/components/RecentlyDeletedBillsDialog.jsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { RotateCcw, Trash2, Loader2 } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { formatUSD } from '@/lib/money'; + +function daysLeftLabel(days) { + if (days == null) return null; + if (days <= 0) return 'purges today'; + if (days === 1) return '1 day left'; + return `${days} days left`; +} + +/** + * Lists bills that were soft-deleted within the 30-day recovery window and lets + * the user restore them — a durable path beyond the transient "Undo" toast. + * + * Presentational: the parent owns the list (`bills`) and the async `onRestore`, + * so restoring refreshes the page's active bills too. + */ +export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) { + const [busyId, setBusyId] = useState(null); + + async function handleRestore(bill) { + setBusyId(bill.id); + try { + await onRestore(bill); + } finally { + setBusyId(null); + } + } + + return ( + + + + Recently deleted + + Deleted bills are kept for 30 days before they’re permanently removed. Restore one to bring it back. + + + + {bills.length === 0 ? ( + + + Nothing to recover + Bills you delete will appear here for 30 days. + + ) : ( + + {bills.map(bill => { + const left = daysLeftLabel(bill.days_left); + const busy = busyId === bill.id; + return ( + + + {bill.name} + + {formatUSD(bill.expected_amount)} + {bill.category_name ? ` · ${bill.category_name}` : ''} + {left ? · {left} : null} + + + handleRestore(bill)} + > + {busy ? : } + Restore + + + ); + })} + + )} + + + ); +} diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx index 51f3fcb..917a2d4 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.jsx @@ -23,6 +23,7 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import BillsTableInner from '@/components/BillsTableInner'; import BillModal from '@/components/BillModal'; +import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog'; import { makeBillDraft } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; @@ -601,6 +602,8 @@ export default function BillsPage() { const [bills, setBills] = useState([]); const [categories, setCategories] = useState([]); const [savedTemplates, setSavedTemplates] = useState([]); + const [deletedBills, setDeletedBills] = useState([]); + const [showDeleted, setShowDeleted] = useState(false); const [loading, setLoading] = useState(true); const [showInactive, setShowInactive] = useState(false); const [search, setSearch] = useState(''); @@ -640,14 +643,16 @@ export default function BillsPage() { const load = useCallback(async () => { try { - const [billsRes, catRes, templateRes] = await Promise.all([ + const [billsRes, catRes, templateRes, deletedRes] = await Promise.all([ api.allBills(), api.categories(), api.billTemplates(), + api.deletedBills().catch(() => []), // non-critical: never block the page ]); setBills(billsRes || []); setCategories(catRes || []); setSavedTemplates(templateRes || []); + setDeletedBills(deletedRes || []); } catch (err) { toast.error(err.message); } finally { @@ -783,6 +788,16 @@ export default function BillsPage() { } } + async function handleRestoreDeleted(bill) { + try { + await api.restoreBill(bill.id); + toast.success(`"${bill.name}" restored`); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to restore bill'); + } + } + async function handleDeleteAlternative() { if (!deleteTarget) return; const bill = deleteTarget; @@ -940,6 +955,17 @@ export default function BillsPage() { )} + {deletedBills.length > 0 && ( + setShowDeleted(true)} + className="h-9 flex-1 gap-2 px-3 text-sm sm:flex-none" + > + + Recently deleted ({deletedBills.length}) + + )} setModal({ bill: null })} className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none" @@ -1142,6 +1168,13 @@ export default function BillsPage() { /> )} + + {/* ── Deactivate confirmation (replaces window.confirm) ── */} { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}> diff --git a/routes/bills.js b/routes/bills.js index 7f63e94..473adda 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -49,6 +49,31 @@ router.get('/', (req, res) => { res.json(bills.map(serializeBill)); }); +// ── GET /api/bills/deleted ──────────────────────────────────────────────────── +// Soft-deleted bills still inside the 30-day recovery window (before the +// retention GC purges them), newest deletion first. Powers the "Recently +// deleted" restore view. Must be declared before GET /:id. +const BILL_RETENTION_DAYS = 30; // matches pruneSoftDeletedFinancialRecords() +router.get('/deleted', (req, res) => { + const db = getDb(); + const rows = db.prepare(` + SELECT b.*, c.name AS category_name, + CAST(julianday(b.deleted_at, '+${BILL_RETENTION_DAYS} days') - julianday('now') AS INTEGER) AS days_left + FROM bills b + LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL + WHERE b.user_id = ? + AND b.deleted_at IS NOT NULL + AND b.deleted_at >= datetime('now', '-${BILL_RETENTION_DAYS} days') + ORDER BY b.deleted_at DESC + `).all(req.user.id); + res.json(rows.map(row => ({ + ...serializeBill(row), + category_name: row.category_name, + deleted_at: row.deleted_at, + days_left: Math.max(0, row.days_left), + }))); +}); + // ── PUT /api/bills/reorder ─────────────────────────────────────────────────── router.put('/reorder', (req, res) => { const db = getDb(); diff --git a/tests/billsDeletedRoute.test.js b/tests/billsDeletedRoute.test.js new file mode 100644 index 0000000..81e1d13 --- /dev/null +++ b/tests/billsDeletedRoute.test.js @@ -0,0 +1,92 @@ +'use strict'; + +// IMP-UX-01: GET /api/bills/deleted lists soft-deleted bills still inside the +// 30-day recovery window (newest first, with days_left), so the "Recently +// deleted" view can offer a restore beyond the transient undo toast. Bills +// purged past the window (or another user's) must not appear. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-bills-deleted-route-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); + +function createUser(db, suffix) { + return db.prepare( + "INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)", + ).run(`deleted-bills-${suffix}`).lastInsertRowid; +} + +// daysAgo === null → an active (not deleted) bill; otherwise soft-deleted N days ago. +function insertBill(db, userId, name, daysAgo) { + const id = db.prepare( + 'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, ?)', + ).run(userId, name, daysAgo == null ? 1 : 0).lastInsertRowid; + if (daysAgo != null) { + db.prepare("UPDATE bills SET deleted_at = datetime('now', ?) WHERE id = ?").run(`-${daysAgo} days`, id); + } + return id; +} + +function callGetDeleted(userId) { + const router = require('../routes/bills'); + const layer = router.stack.find(item => item.route?.path === '/deleted' && item.route.methods.get); + assert.ok(layer, 'GET /deleted route should exist'); + const handler = layer.route.stack[0].handle; + return new Promise((resolve, reject) => { + const req = { query: {}, params: {}, user: { id: userId, role: 'user' } }; + const res = { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(data) { resolve({ status: this.statusCode, data }); }, + }; + try { handler(req, res); } catch (err) { reject(err); } + }); +} + +test.after(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +test('GET /bills/deleted returns only recoverable soft-deleted bills, newest first', async () => { + const db = getDb(); + const userId = createUser(db, 'a'); + + insertBill(db, userId, 'Active Bill', null); // not deleted + insertBill(db, userId, 'Recently Deleted', 2); // 2 days ago + insertBill(db, userId, 'Older Deleted', 20); // 20 days ago + insertBill(db, userId, 'Purgeable', 40); // past the 30-day window + + const { status, data } = await callGetDeleted(userId); + assert.equal(status, 200); + + const names = data.map(b => b.name); + assert.deepEqual(names, ['Recently Deleted', 'Older Deleted'], 'only in-window bills, newest first'); + assert.ok(!names.includes('Active Bill'), 'active bill excluded'); + assert.ok(!names.includes('Purgeable'), 'past-window bill excluded'); + + const recentRow = data.find(b => b.name === 'Recently Deleted'); + assert.ok(recentRow.days_left >= 27 && recentRow.days_left <= 28, `~28 days left, got ${recentRow.days_left}`); + assert.ok(recentRow.deleted_at, 'exposes deleted_at'); + assert.equal(recentRow.expected_amount, 10, 'money serialized to dollars (cents/100)'); +}); + +test('GET /bills/deleted isolates by user', async () => { + const db = getDb(); + const me = createUser(db, 'me'); + const other = createUser(db, 'other'); + insertBill(db, me, 'Mine', 1); + insertBill(db, other, 'Theirs', 1); + + const { data } = await callGetDeleted(me); + const names = data.map(b => b.name); + assert.ok(names.includes('Mine')); + assert.ok(!names.includes('Theirs'), "never leaks another user's deleted bills"); +});
Nothing to recover
Bills you delete will appear here for 30 days.
{bill.name}
+ {formatUSD(bill.expected_amount)} + {bill.category_name ? ` · ${bill.category_name}` : ''} + {left ? · {left} : null} +