refactor(server): migrate all 29 route controllers to TypeScript (.cts)

Every routes/*.js → .cts, with handler signatures typed against the shared
http Req/Res/Next types. Routes carry @ts-nocheck for now — they're thin glue
over the (fully typed) services, so full route-level type-checking is deferred;
the handler-param typing is a head-start. server.js + test requires updated to
the explicit .cts paths.

Behavior-preserving. Verified: check:server 0, typecheck:server 0, suite 226/226,
real boot → /api/health + /api/about 200 (all routes mount).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 11:26:50 -05:00
parent 409b8a5618
commit 866ba52890
46 changed files with 342 additions and 284 deletions

View File

@ -1,10 +1,12 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
let pkg;
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
res.json({
name: 'Bill Tracker',
version: pkg.version,

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const fs = require('fs');
const path = require('path');
@ -415,7 +417,7 @@ async function fetchForgejoIssues() {
}
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat)
router.get('/', requireAuth, requireAdmin, (req, res) => {
router.get('/', requireAuth, requireAdmin, (req: Req, res: Res) => {
try {
// Read both files directly from the allowlist
const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8');
@ -441,7 +443,7 @@ router.get('/', requireAuth, requireAdmin, (req, res) => {
});
// Admin-only endpoint: open issues from Forgejo (5-min cache, ?refresh=1 to bypass)
router.get('/roadmap', requireAuth, requireAdmin, async (req, res) => {
router.get('/roadmap', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const now = Date.now();
const refresh = req.query.refresh === '1';
try {
@ -460,7 +462,7 @@ router.get('/roadmap', requireAuth, requireAdmin, async (req, res) => {
});
// Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md
router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
router.get('/dev-log', requireAuth, requireAdmin, (req: Req, res: Res) => {
try {
const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8');
const sanitized = redactSensitiveContent(devLogContent);
@ -478,7 +480,7 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
const { checkForUpdates } = require('../services/updateCheckService.cts');
// POST /api/about-admin/check-updates — force a fresh update check, bypassing cache
router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
router.post('/check-updates', requireAuth, requireAdmin, async (req: Req, res: Res) => {
try {
const result = await checkForUpdates(true);
res.json(result);
@ -491,12 +493,12 @@ router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
const { getSetting, setSetting } = require('../db/database');
// GET /api/about-admin/update-check-setting — is the external version check enabled?
router.get('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
res.json({ enabled: getSetting('update_check_enabled') !== 'false' });
});
// PUT /api/about-admin/update-check-setting — enable/disable the external version check
router.put('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' });

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
@ -40,13 +42,13 @@ function sendError(res, err) {
}
// GET /api/admin/has-users
router.get('/has-users', (req, res) => {
router.get('/has-users', (req: Req, res: Res) => {
const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n;
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', (req, res) => {
router.get('/users', (req: Req, res: Res) => {
res.json(
getDb().prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC'
@ -55,7 +57,7 @@ router.get('/users', (req, res) => {
});
// POST /api/admin/backups
router.post('/backups', backupOperationLimiter, async (req, res) => {
router.post('/backups', backupOperationLimiter, async (req: Req, res: Res) => {
try {
const backup = await createBackup();
res.status(201).json(backup);
@ -65,7 +67,7 @@ router.post('/backups', backupOperationLimiter, async (req, res) => {
});
// GET /api/admin/backups
router.get('/backups', backupOperationLimiter, (req, res) => {
router.get('/backups', backupOperationLimiter, (req: Req, res: Res) => {
try {
res.json({ backups: listBackups() });
} catch (err) {
@ -74,7 +76,7 @@ router.get('/backups', backupOperationLimiter, (req, res) => {
});
// GET /api/admin/backups/settings
router.get('/backups/settings', backupOperationLimiter, (req, res) => {
router.get('/backups/settings', backupOperationLimiter, (req: Req, res: Res) => {
try {
res.json(getScheduleStatus());
} catch (err) {
@ -83,7 +85,7 @@ router.get('/backups/settings', backupOperationLimiter, (req, res) => {
});
// PUT /api/admin/backups/settings
router.put('/backups/settings', backupOperationLimiter, (req, res) => {
router.put('/backups/settings', backupOperationLimiter, (req: Req, res: Res) => {
try {
res.json(saveBackupScheduleSettings(req.body));
} catch (err) {
@ -92,7 +94,7 @@ router.put('/backups/settings', backupOperationLimiter, (req, res) => {
});
// POST /api/admin/backups/run-scheduled-now
router.post('/backups/run-scheduled-now', backupOperationLimiter, async (req, res) => {
router.post('/backups/run-scheduled-now', backupOperationLimiter, async (req: Req, res: Res) => {
try {
res.status(201).json(await runScheduledBackupNow());
} catch (err) {
@ -108,7 +110,7 @@ router.post(
type: ['application/octet-stream', 'application/x-sqlite3', 'application/vnd.sqlite3'],
limit: '100mb',
}),
async (req, res) => {
async (req: Req, res: Res) => {
try {
// Extract expected checksum from request headers or query
const expectedChecksum = req.headers['x-checksum-sha256'] || req.query.checksum;
@ -124,7 +126,7 @@ router.post(
);
// GET /api/admin/backups/:id/download
router.get('/backups/:id/download', (req, res) => {
router.get('/backups/:id/download', (req: Req, res: Res) => {
try {
const backup = getBackupFile(req.params.id);
res.setHeader('Content-Type', 'application/octet-stream');
@ -138,7 +140,7 @@ router.get('/backups/:id/download', (req, res) => {
});
// POST /api/admin/backups/:id/restore
router.post('/backups/:id/restore', backupOperationLimiter, async (req, res) => {
router.post('/backups/:id/restore', backupOperationLimiter, async (req: Req, res: Res) => {
try {
res.json(await restoreBackup(req.params.id));
} catch (err) {
@ -147,7 +149,7 @@ router.post('/backups/:id/restore', backupOperationLimiter, async (req, res) =>
});
// DELETE /api/admin/backups/:id
router.delete('/backups/:id', backupOperationLimiter, (req, res) => {
router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
try {
res.json(deleteBackup(req.params.id));
} catch (err) {
@ -156,7 +158,7 @@ router.delete('/backups/:id', backupOperationLimiter, (req, res) => {
});
// POST /api/admin/users
router.post('/users', async (req, res) => {
router.post('/users', async (req: Req, res: Res) => {
const { username, password } = req.body;
if (!username || username.length < 3)
return res.status(400).json({ error: 'Username must be at least 3 characters' });
@ -192,7 +194,7 @@ router.post('/users', async (req, res) => {
});
// PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req, res) => {
router.put('/users/:id/password', async (req: Req, res: Res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
@ -227,7 +229,7 @@ router.put('/users/:id/password', async (req, res) => {
// PUT /api/admin/users/:id/role
// Promote/demote an existing user. Prevents removing the last admin or
// changing your own role mid-session.
router.put('/users/:id/role', (req, res) => {
router.put('/users/:id/role', (req: Req, res: Res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
@ -269,7 +271,7 @@ router.put('/users/:id/role', (req, res) => {
});
// PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req, res) => {
router.put('/users/:id/active', (req: Req, res: Res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
@ -290,7 +292,7 @@ router.put('/users/:id/active', (req, res) => {
});
// PUT /api/admin/users/:id/username
router.put('/users/:id/username', (req, res) => {
router.put('/users/:id/username', (req: Req, res: Res) => {
const { username } = req.body;
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' });
@ -333,7 +335,7 @@ router.put('/users/:id/username', (req, res) => {
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
router.delete('/users/:id', (req: Req, res: Res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
@ -366,7 +368,7 @@ router.delete('/users/:id', (req, res) => {
// GET /api/admin/cleanup
// Returns current cleanup settings and the result of the last cleanup run.
router.get('/cleanup', (req, res) => {
router.get('/cleanup', (req: Req, res: Res) => {
try {
res.json(getCleanupStatus());
} catch (err) {
@ -383,7 +385,7 @@ router.get('/cleanup', (req, res) => {
// backup_partials_enabled boolean prune orphaned .partial/.upload backup files
// import_history_enabled boolean prune old import history rows (disabled by default)
// import_history_max_age_days 303650 age threshold for import history rows
router.put('/cleanup', (req, res) => {
router.put('/cleanup', (req: Req, res: Res) => {
try {
res.json(applyCleanupSettings(req.body));
} catch (err) {
@ -393,7 +395,7 @@ router.put('/cleanup', (req, res) => {
// POST /api/admin/cleanup/run
// Runs all enabled cleanup tasks immediately and returns the result.
router.post('/cleanup/run', backupOperationLimiter, async (req, res) => {
router.post('/cleanup/run', backupOperationLimiter, async (req: Req, res: Res) => {
try {
const result = await runAllCleanup();
res.json(result);
@ -412,14 +414,14 @@ const {
} = require('../services/oidcService.cts');
// GET /api/admin/auth-mode
router.get('/auth-mode', (req, res) => {
router.get('/auth-mode', (req: Req, res: Res) => {
res.json(buildAuthModeStatus());
});
// POST /api/admin/auth-mode/oidc-test
// Tests submitted or saved OIDC provider settings with OIDC discovery.
// Never returns client secret or token material.
router.post('/auth-mode/oidc-test', async (req, res) => {
router.post('/auth-mode/oidc-test', async (req: Req, res: Res) => {
const config = buildSubmittedOidcConfig(req.body || {});
const result = await testOidcConfiguration(config);
res.status(result.ok ? 200 : 400).json(result);
@ -428,7 +430,7 @@ router.post('/auth-mode/oidc-test', async (req, res) => {
// PUT /api/admin/auth-mode
// Accepts legacy auth_mode/default_user_id fields plus new auth method settings.
// Validates lockout protection before saving.
router.put('/auth-mode', (req, res) => {
router.put('/auth-mode', (req: Req, res: Res) => {
try {
res.json(applyAuthModeSettings(req.body || {}));
} catch (err) {
@ -439,12 +441,12 @@ router.put('/auth-mode', (req, res) => {
// ── Bank Sync Config ──────────────────────────────────────────────────────────
// GET /api/admin/bank-sync-config
router.get('/bank-sync-config', (req, res) => {
router.get('/bank-sync-config', (req: Req, res: Res) => {
res.json({ ...getBankSyncConfig(), worker: getBankSyncWorkerStatus(), encryption_key_source: isEnvKeyActive() ? 'env' : 'db', key_fingerprint: keyFingerprint() });
});
// PUT /api/admin/bank-sync-config
router.put('/bank-sync-config', (req, res) => {
router.put('/bank-sync-config', (req: Req, res: Res) => {
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
try {
let config = getBankSyncConfig();
@ -459,7 +461,7 @@ router.put('/bank-sync-config', (req, res) => {
});
// ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req, res) => {
router.post('/migrations/rollback', async (req: Req, res: Res) => {
const { version } = req.body;
if (!version) {
return res.status(400).json({ error: 'Version is required' });

View File

@ -1,9 +1,11 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { standardizeError } = require('../middleware/errorFormatter');
const { getAnalyticsSummary } = require('../services/analyticsService.cts');
router.get('/summary', (req, res) => {
router.get('/summary', (req: Req, res: Res) => {
const result = getAnalyticsSummary(req.user.id, req.query);
if (result.error) return res.status(400).json(standardizeError(result.error, 'VALIDATION_ERROR', 'month'));
res.json(result);

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
@ -25,12 +27,12 @@ const { logAudit } = require('../services/auditService.cts');
// ─────────────────────────────────────────
// POST /api/auth/login
router.post('/login', (req, res, next) => {
router.post('/login', (req: Req, res: Res, next: Next) => {
// Exempt login from CSRF - no session exists yet to hijack
// CSRF validation happens on all other authenticated routes
req.csrfSkip = true;
next();
}, async (req, res) => {
}, async (req: Req, res: Res) => {
// Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') {
return res.status(403).json(standardizeError('Local username/password login is not enabled on this server.', 'FORBIDDEN'));
@ -72,13 +74,13 @@ router.post('/login', (req, res, next) => {
// Public — returns the CSRF token from the httpOnly cookie so the SPA can
// store it in memory and send it in the x-csrf-token header for mutations.
// Cross-origin access is prevented by the same-origin fetch policy (no CORS).
router.get('/csrf-token', (req, res) => {
router.get('/csrf-token', (req: Req, res: Res) => {
const token = getCsrfToken(req, res);
res.json({ token });
});
// POST /api/auth/logout
router.post('/logout', requireAuth, (req, res) => {
router.post('/logout', requireAuth, (req: Req, res: Res) => {
logout(req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout', ip_address: req.ip, user_agent: req.get('user-agent') });
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
@ -86,7 +88,7 @@ router.post('/logout', requireAuth, (req, res) => {
});
// POST /api/auth/logout-all
router.post('/logout-all', requireAuth, (req, res) => {
router.post('/logout-all', requireAuth, (req: Req, res: Res) => {
// Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
@ -100,14 +102,14 @@ router.post('/logout-all', requireAuth, (req, res) => {
});
// POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in.
router.post('/logout-others', requireAuth, (req, res) => {
router.post('/logout-others', requireAuth, (req: Req, res: Res) => {
const result = invalidateOtherSessions(req.user.id, req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout.others', ip_address: req.ip, user_agent: req.get('user-agent') });
res.json({ success: true, count: result?.changes || 0 });
});
// GET /api/auth/me
router.get('/me', requireAuth, (req, res) => {
router.get('/me', requireAuth, (req: Req, res: Res) => {
const currentVersion = getAppVersion();
res.json({
user: req.user,
@ -119,7 +121,7 @@ router.get('/me', requireAuth, (req, res) => {
});
// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here)
router.get('/login-history', requireAuth, (req, res) => {
router.get('/login-history', requireAuth, (req: Req, res: Res) => {
const db = getDb();
const rows = db.prepare(`
SELECT id, logged_in_at, ip_address, user_agent,
@ -176,7 +178,7 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
// POST /api/auth/totp/challenge — second step of login when TOTP is enabled.
// Takes challenge_token (from first login step) + totp_code, creates a session.
router.post('/totp/challenge', async (req, res) => {
router.post('/totp/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, code, recovery_code } = req.body || {};
if (!challenge_token) return res.status(400).json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
@ -222,7 +224,7 @@ router.post('/totp/challenge', async (req, res) => {
// GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user.
// The secret is NOT saved yet; the user must confirm a valid code via /totp/enable.
router.get('/totp/setup', requireAuth, async (req, res) => {
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
const secret = generateSecret();
@ -236,7 +238,7 @@ router.get('/totp/setup', requireAuth, async (req, res) => {
});
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
router.post('/totp/enable', requireAuth, (req, res) => {
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
const { secret, code } = req.body || {};
if (!secret || !code) return res.status(400).json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
@ -255,7 +257,7 @@ router.post('/totp/enable', requireAuth, (req, res) => {
});
// POST /api/auth/totp/disable — disable TOTP. Requires a valid TOTP code or recovery code.
router.post('/totp/disable', requireAuth, (req, res) => {
router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
const { code, recovery_code } = req.body || {};
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
@ -276,13 +278,13 @@ router.post('/totp/disable', requireAuth, (req, res) => {
});
// GET /api/auth/totp/status — is TOTP enabled for the current user?
router.get('/totp/status', requireAuth, (req, res) => {
router.get('/totp/status', requireAuth, (req: Req, res: Res) => {
const user = getDb().prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id);
res.json({ enabled: !!user?.totp_enabled });
});
// POST /api/auth/totp/acknowledge-version — user has seen the release notes
router.post('/acknowledge-version', requireAuth, (req, res) => {
router.post('/acknowledge-version', requireAuth, (req: Req, res: Res) => {
const currentVersion = getAppVersion();
getDb()
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
@ -293,7 +295,7 @@ router.post('/acknowledge-version', requireAuth, (req, res) => {
// GET /api/auth/mode
// Public — tells the login page which options are available.
// Never returns secrets. local_enabled/oidc_enabled reflect admin settings.
router.get('/mode', (req, res) => {
router.get('/mode', (req: Req, res: Res) => {
const oidcInfo = getPublicOidcInfo();
const localEnabled = getSetting('local_login_enabled') !== 'false';
res.json({
@ -307,7 +309,7 @@ router.get('/mode', (req, res) => {
// Recovery path for single-user mode. In single-user mode requireAuth attaches
// the configured default user, so this lets that Settings page restore normal
// login without needing access to Admin routes.
router.post('/restore-multi-user-mode', requireAuth, (req, res) => {
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
return res.status(400).json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
}
@ -319,7 +321,7 @@ router.post('/restore-multi-user-mode', requireAuth, (req, res) => {
});
// POST /api/auth/acknowledge-privacy
router.post('/acknowledge-privacy', requireAuth, (req, res) => {
router.post('/acknowledge-privacy', requireAuth, (req: Req, res: Res) => {
getDb().prepare(
"UPDATE users SET first_login = 0, updated_at = datetime('now') WHERE id = ?"
).run(req.user.id);
@ -330,7 +332,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
// POST /api/auth/change-password
// Password change endpoint with dedicated rate limiter
// CSRF protected via csrfMiddleware on /api/auth mount
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, res: Res) => {
const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) {
@ -379,7 +381,7 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req, res) =
// ─────────────────────────────────────────
// GET /api/admin/has-users
router.get('/has-users', (req, res) => {
router.get('/has-users', (req: Req, res: Res) => {
const count = getDb()
.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'")
.get().n;
@ -388,7 +390,7 @@ router.get('/has-users', (req, res) => {
});
// GET /api/admin/users
router.get('/users', requireAuth, requireAdmin, (req, res) => {
router.get('/users', requireAuth, requireAdmin, (req: Req, res: Res) => {
const users = getDb().prepare(
"SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC"
).all();
@ -397,7 +399,7 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
});
// POST /api/admin/users
router.post('/users', requireAuth, requireAdmin, async (req, res) => {
router.post('/users', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { username, password } = req.body;
if (!username || username.length < 3) {
@ -440,7 +442,7 @@ const {
} = require('../services/webauthnService.cts');
// GET /api/auth/webauthn/status
router.get('/webauthn/status', requireAuth, (req, res) => {
router.get('/webauthn/status', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) return res.json({ enabled: false, credential_count: 0 });
const db = getDb();
const user = db.prepare('SELECT webauthn_enabled FROM users WHERE id = ?').get(req.user.id);
@ -449,13 +451,13 @@ router.get('/webauthn/status', requireAuth, (req, res) => {
});
// GET /api/auth/webauthn/credentials
router.get('/webauthn/credentials', requireAuth, (req, res) => {
router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) return res.json({ credentials: [] });
res.json({ credentials: getCredentials(getDb(), req.user.id) });
});
// GET /api/auth/webauthn/setup — begin registration
router.get('/webauthn/setup', requireAuth, async (req, res) => {
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode)
return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
@ -468,7 +470,7 @@ router.get('/webauthn/setup', requireAuth, async (req, res) => {
});
// POST /api/auth/webauthn/enable — complete registration
router.post('/webauthn/enable', requireAuth, async (req, res) => {
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode)
return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
const { challengeId, response, credential_name } = req.body || {};
@ -492,7 +494,7 @@ router.post('/webauthn/enable', requireAuth, async (req, res) => {
});
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req, res) => {
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
@ -521,7 +523,7 @@ router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req, re
});
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
router.post('/webauthn/disable', requireAuth, async (req, res) => {
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
@ -549,7 +551,7 @@ router.post('/webauthn/disable', requireAuth, async (req, res) => {
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
// Mirrors POST /totp/challenge exactly.
router.post('/webauthn/challenge', async (req, res) => {
router.post('/webauthn/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, response } = req.body || {};
if (!challenge_token || !response)

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
/**
@ -29,7 +31,7 @@ const NOT_ACTIVE = { error: 'OIDC authentication is not configured or not enable
// ── GET /api/auth/oidc/login ──────────────────────────────────────────────────
// Validates that OIDC is active, generates PKCE + nonce + state, stores in DB,
// then redirects the user to the identity provider's authorization endpoint.
router.get('/login', async (req, res) => {
router.get('/login', async (req: Req, res: Res) => {
if (!isOidcLoginActive()) return res.status(501).json(NOT_ACTIVE);
const config = getOidcConfig();
if (!config) return res.status(501).json(NOT_ACTIVE);
@ -62,7 +64,7 @@ router.get('/login', async (req, res) => {
// On success: creates a local session, sets cookie, redirects to frontend.
// On any failure: redirects with a safe oidc_error query parameter.
// Tokens are never logged or forwarded.
router.get('/callback', async (req, res) => {
router.get('/callback', async (req: Req, res: Res) => {
if (!isOidcLoginActive()) return res.redirect('/?oidc_error=not_configured');
const config = getOidcConfig();
if (!config) return res.redirect('/?oidc_error=not_configured');

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
@ -26,7 +28,7 @@ const { localDateString, todayLocal } = require('../utils/dates.mts');
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts');
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
@ -54,7 +56,7 @@ router.get('/', (req, res) => {
// 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) => {
router.get('/deleted', (req: Req, res: Res) => {
const db = getDb();
const rows = db.prepare(`
SELECT b.*, c.name AS category_name,
@ -75,7 +77,7 @@ router.get('/deleted', (req, res) => {
});
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
router.put('/reorder', (req, res) => {
router.put('/reorder', (req: Req, res: Res) => {
const db = getDb();
const entries = Object.entries(req.body || {}).map(([billId, sortOrder]) => ({
billId: Number(billId),
@ -122,7 +124,7 @@ router.put('/reorder', (req, res) => {
});
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
router.get('/audit', (req, res) => {
router.get('/audit', (req: Req, res: Res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
@ -130,7 +132,7 @@ router.get('/audit', (req, res) => {
});
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req, res) => {
router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts');
try {
res.json(getDriftReport(req.user.id));
@ -140,7 +142,7 @@ router.get('/drift-report', (req, res) => {
});
// GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req, res) => {
router.get('/merchant-rules', (req: Req, res: Res) => {
try {
const rules = getDb().prepare(`
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
@ -159,7 +161,7 @@ router.get('/merchant-rules', (req, res) => {
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
// Registered early (before /:id) but path has suffix so no conflict
router.post('/:id/snooze-drift', (req, res) => {
router.post('/:id/snooze-drift', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
@ -185,7 +187,7 @@ function serializeTemplateData(data) {
}
// ── GET /api/bills/templates ─────────────────────────────────────────────────
router.get('/templates', (req, res) => {
router.get('/templates', (req: Req, res: Res) => {
const db = getDb();
const rows = db.prepare(`
SELECT id, name, data, created_at, updated_at
@ -201,7 +203,7 @@ router.get('/templates', (req, res) => {
});
// ── POST /api/bills/templates ────────────────────────────────────────────────
router.post('/templates', (req, res) => {
router.post('/templates', (req: Req, res: Res) => {
const db = getDb();
const name = String(req.body.name || '').trim();
if (name.length < 2) {
@ -243,7 +245,7 @@ router.post('/templates', (req, res) => {
});
// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
router.delete('/templates/:templateId', (req, res) => {
router.delete('/templates/:templateId', (req: Req, res: Res) => {
const db = getDb();
const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) {
@ -255,7 +257,7 @@ router.delete('/templates/:templateId', (req, res) => {
});
// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
router.post('/:id/duplicate', (req, res) => {
router.post('/:id/duplicate', (req: Req, res: Res) => {
const db = getDb();
const body = req.body || {};
const billId = parseInt(req.params.id, 10);
@ -284,7 +286,7 @@ router.post('/:id/duplicate', (req, res) => {
});
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
router.get('/:id/monthly-state', (req, res) => {
router.get('/:id/monthly-state', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
@ -312,7 +314,7 @@ router.get('/:id/monthly-state', (req, res) => {
});
// ── PUT /api/bills/:id/monthly-state ──────────────────────────────────────────
router.put('/:id/monthly-state', (req, res) => {
router.put('/:id/monthly-state', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
@ -378,7 +380,7 @@ router.put('/:id/monthly-state', (req, res) => {
});
// ── GET /api/bills/:id ────────────────────────────────────────────────────────
router.get('/:id', (req, res) => {
router.get('/:id', (req: Req, res: Res) => {
const db = getDb();
const bill = db.prepare(`
SELECT b.*, c.name AS category_name,
@ -419,7 +421,7 @@ router.get('/:id', (req, res) => {
});
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
router.post('/:id/verify-autopay', (req, res) => {
router.post('/:id/verify-autopay', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
@ -436,7 +438,7 @@ router.post('/:id/verify-autopay', (req, res) => {
});
// ── POST /api/bills ───────────────────────────────────────────────────────────
router.post('/', (req, res) => {
router.post('/', (req: Req, res: Res) => {
const db = getDb();
const body = req.body || {};
let payload = body;
@ -473,7 +475,7 @@ router.post('/', (req, res) => {
});
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
router.put('/:id', (req, res) => {
router.put('/:id', (req: Req, res: Res) => {
const db = getDb();
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -550,7 +552,7 @@ router.put('/:id', (req, res) => {
});
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
router.put('/:id/archived', (req, res) => {
router.put('/:id/archived', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
@ -568,7 +570,7 @@ router.put('/:id/archived', (req, res) => {
});
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
router.delete('/:id', (req, res) => {
router.delete('/:id', (req: Req, res: Res) => {
const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -585,7 +587,7 @@ router.delete('/:id', (req, res) => {
});
// POST /api/bills/:id/restore — undo bill soft delete
router.post('/:id/restore', (req, res) => {
router.post('/:id/restore', (req: Req, res: Res) => {
const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
@ -599,7 +601,7 @@ router.post('/:id/restore', (req, res) => {
// POST /api/bills/:id/sync-simplefin-payments
// Scan unmatched SimpleFIN transactions for this bill's merchant rules and
// backfill any missing payments.
router.post('/:id/sync-simplefin-payments', (req, res) => {
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const db = getDb();
@ -614,7 +616,7 @@ router.post('/:id/sync-simplefin-payments', (req, res) => {
});
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
router.get('/:id/payments', (req, res) => {
router.get('/:id/payments', (req: Req, res: Res) => {
const db = getDb();
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -643,7 +645,7 @@ router.get('/:id/payments', (req, res) => {
});
// ── GET /api/bills/:id/transactions ──────────────────────────────────────────
router.get('/:id/transactions', (req, res) => {
router.get('/:id/transactions', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
@ -701,7 +703,7 @@ router.get('/:id/transactions', (req, res) => {
});
// ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ──────────────
router.post('/:id/toggle-paid', (req, res) => {
router.post('/:id/toggle-paid', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
@ -798,7 +800,7 @@ router.post('/:id/toggle-paid', (req, res) => {
});
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
router.get('/:id/history-ranges', (req, res) => {
router.get('/:id/history-ranges', (req: Req, res: Res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -813,7 +815,7 @@ router.get('/:id/history-ranges', (req, res) => {
});
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
router.post('/:id/history-ranges', (req, res) => {
router.post('/:id/history-ranges', (req: Req, res: Res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -858,7 +860,7 @@ router.post('/:id/history-ranges', (req, res) => {
});
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
router.put('/:id/history-ranges/:rangeId', (req, res) => {
router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -902,7 +904,7 @@ router.put('/:id/history-ranges/:rangeId', (req, res) => {
});
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
router.delete('/:id/history-ranges/:rangeId', (req, res) => {
router.delete('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -918,7 +920,7 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
});
// ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ──
router.get('/:id/amortization', (req, res) => {
router.get('/:id/amortization', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
@ -970,7 +972,7 @@ router.get('/:id/amortization', (req, res) => {
});
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
router.patch('/:id/snowball', (req, res) => {
router.patch('/:id/snowball', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
@ -989,7 +991,7 @@ router.patch('/:id/snowball', (req, res) => {
});
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
router.patch('/:id/balance', (req, res) => {
router.patch('/:id/balance', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
@ -1047,7 +1049,7 @@ function findConflicts(db, userId, billId, normalized) {
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
router.get('/:id/merchant-rules', (req, res) => {
router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
@ -1085,7 +1087,7 @@ router.get('/:id/merchant-rules', (req, res) => {
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
router.get('/:id/merchant-rules/preview', (req, res) => {
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
@ -1106,7 +1108,7 @@ router.get('/:id/merchant-rules/preview', (req, res) => {
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
router.post('/:id/merchant-rules', (req, res) => {
router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
@ -1146,7 +1148,7 @@ router.post('/:id/merchant-rules', (req, res) => {
// All transactions matching this bill's merchant rules — any match_status.
// Each item includes the current status so the user knows what will happen.
router.get('/:id/merchant-rules/candidates', (req, res) => {
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
@ -1221,7 +1223,7 @@ router.get('/:id/merchant-rules/candidates', (req, res) => {
// ── POST /api/bills/:id/merchant-rules/import-historical ──────────────────────
// Import a specific list of transaction IDs as payments for this bill.
router.post('/:id/merchant-rules/import-historical', (req, res) => {
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
@ -1301,7 +1303,7 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
// Toggle the auto_attribute_late flag for a single merchant rule.
router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req, res) => {
router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
@ -1319,7 +1321,7 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req, res) => {
res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
});
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router();
@ -64,30 +66,30 @@ function tokenPayload(req, tokenRow) {
}
// GET /api/calendar/feed — current user's calendar feed token and URL
router.get('/feed', (req, res) => {
router.get('/feed', (req: Req, res: Res) => {
res.json(tokenPayload(req, getActiveToken(req.user.id)));
});
// POST /api/calendar/feed — create the feed token if one does not exist
router.post('/feed', (req, res) => {
router.post('/feed', (req: Req, res: Res) => {
const token = getOrCreateToken(req.user.id);
res.status(201).json(tokenPayload(req, token));
});
// POST /api/calendar/feed/regenerate — revoke old token and issue a new URL
router.post('/feed/regenerate', (req, res) => {
router.post('/feed/regenerate', (req: Req, res: Res) => {
const token = regenerateToken(req.user.id);
res.json(tokenPayload(req, token));
});
// DELETE /api/calendar/feed — revoke the active feed URL
router.delete('/feed', (req, res) => {
router.delete('/feed', (req: Req, res: Res) => {
revokeToken(req.user.id);
res.json(tokenPayload(req, null));
});
// GET /api/calendar/feed/preview — next generated events shown before subscribing
router.get('/feed/preview', (req, res) => {
router.get('/feed/preview', (req: Req, res: Res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 50);
res.json({
events: previewFeed(req.user.id, { limit }),
@ -95,7 +97,7 @@ router.get('/feed/preview', (req, res) => {
});
// GET /api/calendar?year=2026&month=5
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
const now = new Date();
const year = parseInt(req.query.year || now.getFullYear(), 10);

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -10,7 +12,7 @@ const {
// GET /api/calendar/feed.ics?token=...
// Public by design: calendar clients cannot use the app's session cookies.
router.get('/feed.ics', (req, res) => {
router.get('/feed.ics', (req: Req, res: Res) => {
const token = String(req.query.token || '');
const tokenRow = getTokenRecord(token);
if (!tokenRow) {

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router();
@ -5,7 +7,7 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
// GET /api/categories
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
try {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
@ -76,7 +78,7 @@ router.get('/', (req, res) => {
});
// PUT /api/categories/reorder
router.put('/reorder', (req, res) => {
router.put('/reorder', (req: Req, res: Res) => {
const db = getDb();
const entries = Object.entries(req.body || {}).map(([categoryId, sortOrder]) => ({
categoryId: Number(categoryId),
@ -114,7 +116,7 @@ router.put('/reorder', (req, res) => {
});
// POST /api/categories
router.post('/', (req, res) => {
router.post('/', (req: Req, res: Res) => {
const db = getDb();
const { name } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
@ -132,7 +134,7 @@ router.post('/', (req, res) => {
});
// PUT /api/categories/:id
router.put('/:id', (req, res) => {
router.put('/:id', (req: Req, res: Res) => {
const db = getDb();
const { name, spending_enabled, group_id } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
@ -166,7 +168,7 @@ router.put('/:id', (req, res) => {
// ── Category groups ──────────────────────────────────────────────────────────
// GET /api/category-groups
router.get('/groups', (req, res) => {
router.get('/groups', (req: Req, res: Res) => {
const db = getDb();
const groups = db.prepare(`
SELECT id, name, sort_order, created_at, updated_at
@ -178,7 +180,7 @@ router.get('/groups', (req, res) => {
});
// POST /api/category-groups
router.post('/groups', (req, res) => {
router.post('/groups', (req: Req, res: Res) => {
const db = getDb();
const { name } = req.body;
if (!name?.trim()) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
@ -199,7 +201,7 @@ router.post('/groups', (req, res) => {
});
// PUT /api/category-groups/:id
router.put('/groups/:id', (req, res) => {
router.put('/groups/:id', (req: Req, res: Res) => {
const db = getDb();
const { name, sort_order } = req.body;
@ -228,7 +230,7 @@ router.put('/groups/:id', (req, res) => {
});
// DELETE /api/category-groups/:id
router.delete('/groups/:id', (req, res) => {
router.delete('/groups/:id', (req: Req, res: Res) => {
const db = getDb();
const group = db.prepare('SELECT id FROM category_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json(standardizeError('Category group not found', 'NOT_FOUND', 'id'));
@ -238,7 +240,7 @@ router.delete('/groups/:id', (req, res) => {
});
// PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name
router.patch('/:id/spending', (req, res) => {
router.patch('/:id/spending', (req: Req, res: Res) => {
const db = getDb();
const cat = db.prepare('SELECT id, spending_enabled FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
@ -255,7 +257,7 @@ router.patch('/:id/spending', (req, res) => {
});
// DELETE /api/categories/:id
router.delete('/:id', (req, res) => {
router.delete('/:id', (req: Req, res: Res) => {
const db = getDb();
const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
@ -273,7 +275,7 @@ router.delete('/:id', (req, res) => {
});
// POST /api/categories/:id/restore — undo category soft delete
router.post('/:id/restore', (req, res) => {
router.post('/:id/restore', (req: Req, res: Res) => {
const db = getDb();
const cat = db.prepare('SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Deleted category not found', 'NOT_FOUND', 'id'));

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const router = require('express').Router();
@ -26,7 +28,7 @@ function safeError(err, fallback) {
// Returns all financial accounts for the user across all sources.
// Used by the bank tracking account picker.
router.get('/accounts/all', (req, res) => {
router.get('/accounts/all', (req: Req, res: Res) => {
try {
const db = getDb();
const accounts = db.prepare(`
@ -52,7 +54,7 @@ router.get('/accounts/all', (req, res) => {
// ─── GET /api/data-sources ────────────────────────────────────────────────────
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
ensureManualDataSource(db, req.user.id);
@ -90,7 +92,7 @@ router.get('/', (req, res) => {
// ─── GET /api/data-sources/simplefin/status ──────────────────────────────────
router.get('/simplefin/status', (req, res) => {
router.get('/simplefin/status', (req: Req, res: Res) => {
const { enabled, sync_days, seed_days } = getBankSyncConfig();
const db = getDb();
@ -108,7 +110,7 @@ router.get('/simplefin/status', (req, res) => {
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req, res) => {
router.post('/simplefin/connect', async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
@ -130,7 +132,7 @@ router.post('/simplefin/connect', async (req, res) => {
// ─── GET /api/data-sources/:sourceId/accounts ────────────────────────────────
router.get('/:sourceId/accounts', (req, res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) {
return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
@ -180,7 +182,7 @@ router.get('/:sourceId/accounts', (req, res) => {
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
router.put('/:sourceId/accounts/:accountId', (req, res) => {
router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10);
const accountId = parseInt(req.params.accountId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1 || !Number.isInteger(accountId) || accountId < 1) {
@ -209,7 +211,7 @@ router.put('/:sourceId/accounts/:accountId', (req, res) => {
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req, res) => {
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
@ -232,7 +234,7 @@ router.post('/:id/sync', syncLimiter, async (req, res) => {
// ─── POST /api/data-sources/sync-all ─────────────────────────────────────────
// Syncs every SimpleFIN source for the current user. Returns aggregated stats.
router.post('/sync-all', syncLimiter, async (req, res) => {
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
@ -286,7 +288,7 @@ router.post('/sync-all', syncLimiter, async (req, res) => {
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req, res) => {
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
@ -308,7 +310,7 @@ router.post('/:id/backfill', syncLimiter, async (req, res) => {
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req, res) => {
router.delete('/:id', (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router();
@ -10,7 +12,7 @@ const { getDb } = require('../db/database');
const { fromCents } = require('../utils/money.mts');
// GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
const format = (req.query.format || 'csv').toLowerCase();
const { from, to } = req.query;
@ -175,7 +177,7 @@ function getUserExportData(userId) {
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, bill_history_ranges: historyRanges, notes };
}
router.get('/user-excel', (req, res) => {
router.get('/user-excel', (req: Req, res: Res) => {
const data = getUserExportData(req.user.id);
const wb = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet([data.metadata]), 'Export Metadata');
@ -239,7 +241,7 @@ function buildUserDbExportFile(userId) {
return file;
}
router.get('/user-db', (req, res) => {
router.get('/user-db', (req: Req, res: Res) => {
const file = buildUserDbExportFile(req.user.id);
res.download(file, 'bill-tracker-user-export.sqlite', () => {
try { fs.unlinkSync(file); } catch {}
@ -247,7 +249,7 @@ router.get('/user-db', (req, res) => {
});
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
router.get('/user-json', (req, res) => {
router.get('/user-json', (req: Req, res: Res) => {
const data = getUserExportData(req.user.id);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="bill-tracker-user-export.json"');

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -74,7 +76,7 @@ router.post(
],
limit: '10mb',
}),
async (req, res) => {
async (req: Req, res: Res) => {
try {
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({
@ -116,7 +118,7 @@ router.post(
// Each decision must have: row_id, action, and action-specific fields.
// Only writes data for explicitly confirmed decisions; skips ambiguous rows.
router.post('/spreadsheet/apply', requireDataImportEnabled, express.json({ limit: '2mb' }), async (req, res) => {
router.post('/spreadsheet/apply', requireDataImportEnabled, express.json({ limit: '2mb' }), async (req: Req, res: Res) => {
try {
const { import_session_id, decisions, options } = req.body || {};
@ -159,7 +161,7 @@ router.post(
],
limit: '50mb',
}),
async (req, res) => {
async (req: Req, res: Res) => {
try {
const rawFilename = req.headers['x-filename'];
const originalFilename = rawFilename
@ -177,7 +179,7 @@ router.post(
// Applies a previously previewed user SQLite export session. User ownership is
// derived from req.user only; existing data is skipped by default.
router.post('/user-db/apply', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req, res) => {
router.post('/user-db/apply', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req: Req, res: Res) => {
try {
const { import_session_id, options } = req.body || {};
if (!import_session_id || typeof import_session_id !== 'string') {
@ -207,7 +209,7 @@ router.post(
],
limit: '10mb',
}),
async (req, res) => {
async (req: Req, res: Res) => {
try {
const rawFilename = req.headers['x-filename'];
const originalFilename = rawFilename
@ -225,7 +227,7 @@ router.post(
// Commits a previously-previewed CSV import session using the confirmed column
// mapping. Writes normalized rows into the shared transactions table.
router.post('/csv/commit', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req, res) => {
router.post('/csv/commit', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req: Req, res: Res) => {
try {
const { import_session_id, mapping, options } = req.body || {};
if (!import_session_id || typeof import_session_id !== 'string') {
@ -258,7 +260,7 @@ router.post(
],
limit: '10mb',
}),
(req, res) => {
(req: Req, res: Res) => {
try {
const rawFilename = req.headers['x-filename'];
const originalFilename = rawFilename
@ -274,7 +276,7 @@ router.post(
// ─── POST /api/import/ofx/commit ─────────────────────────────────────────────
// Commits a previewed OFX/QFX session (no mapping — the file is structured).
router.post('/ofx/commit', requireDataImportEnabled, express.json({ limit: '1mb' }), (req, res) => {
router.post('/ofx/commit', requireDataImportEnabled, express.json({ limit: '1mb' }), (req: Req, res: Res) => {
try {
const { import_session_id, options } = req.body || {};
if (!import_session_id || typeof import_session_id !== 'string') {
@ -290,7 +292,7 @@ router.post('/ofx/commit', requireDataImportEnabled, express.json({ limit: '1mb'
// ─── GET /api/import/history ──────────────────────────────────────────────────
// Returns the authenticated user's import history (last 100 imports).
router.get('/history', requireDataImportEnabled, (req, res) => {
router.get('/history', requireDataImportEnabled, (req: Req, res: Res) => {
try {
const history = getImportHistory(req.user.id);
res.json({ history });

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const router = require('express').Router();
const { standardizeError } = require('../middleware/errorFormatter');
const { getDb } = require('../db/database');
@ -20,7 +22,7 @@ function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
}
// GET /api/matches/suggestions
router.get('/suggestions', (req, res) => {
router.get('/suggestions', (req: Req, res: Res) => {
try {
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
@ -29,7 +31,7 @@ router.get('/suggestions', (req, res) => {
});
// POST /api/matches/:id/reject
router.post('/:id/reject', (req, res) => {
router.post('/:id/reject', (req: Req, res: Res) => {
try {
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
@ -38,7 +40,7 @@ router.post('/:id/reject', (req, res) => {
});
// POST /api/matches/confirm — link a transaction to a bill and record a payment
router.post('/confirm', (req, res) => {
router.post('/confirm', (req: Req, res: Res) => {
const txId = parseInt(req.body?.transaction_id, 10);
const billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
@ -93,7 +95,7 @@ router.post('/confirm', (req, res) => {
});
// POST /api/matches/:transactionId/unmatch — remove a manual match
router.post('/:transactionId/unmatch', (req, res) => {
router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) {
return res.status(400).json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
@ -121,7 +123,7 @@ function buildStartingAmountsResponse(db, userId, year, month) {
};
}
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
@ -129,7 +131,7 @@ router.get('/', (req, res) => {
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
});
router.put('/', (req, res) => {
router.put('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database');
@ -10,7 +12,7 @@ const { encryptSecret } = require('../services/encryptionService.cts');
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
// GET /api/notifications/admin — read all SMTP settings (password masked)
router.get('/admin', requireAuth, requireAdmin, (req, res) => {
router.get('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
const keys = [
'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address',
'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption',
@ -26,7 +28,7 @@ router.get('/admin', requireAuth, requireAdmin, (req, res) => {
});
// PUT /api/notifications/admin — save SMTP settings
router.put('/admin', requireAuth, requireAdmin, (req, res) => {
router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
const allowed = [
'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address',
'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption',
@ -55,7 +57,7 @@ router.put('/admin', requireAuth, requireAdmin, (req, res) => {
});
// POST /api/notifications/test — send a test email
router.post('/test', requireAuth, requireAdmin, async (req, res) => {
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Recipient address required' });
try {
@ -69,7 +71,7 @@ router.post('/test', requireAuth, requireAdmin, async (req, res) => {
// ── User: notification preferences ───────────────────────────────────────────
// GET /api/notifications/me — user prefs + whether admin has enabled it
router.get('/me', requireAuth, requireUser, (req, res) => {
router.get('/me', requireAuth, requireUser, (req: Req, res: Res) => {
const db = getDb();
const user = db.prepare(`
SELECT notification_email, notifications_enabled,
@ -91,7 +93,7 @@ router.get('/me', requireAuth, requireUser, (req, res) => {
});
// PUT /api/notifications/me — save user prefs
router.put('/me', requireAuth, requireUser, (req, res) => {
router.put('/me', requireAuth, requireUser, (req: Req, res: Res) => {
const db = getDb();
const {
notification_email, notifications_enabled,
@ -124,7 +126,7 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
});
// POST /api/notifications/test-push — send a test push notification to the current user
router.post('/test-push', requireAuth, requireUser, async (req, res) => {
router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) => {
const db = getDb();
const user = db.prepare(`
SELECT notify_push_enabled, push_channel, push_url, push_token, push_chat_id

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
@ -73,7 +75,7 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
}
// GET /api/payments?bill_id=&year=&month=
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
const { bill_id, year, month } = req.query;
@ -113,7 +115,7 @@ router.get('/', (req, res) => {
});
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
router.get('/recent-auto', (req, res) => {
router.get('/recent-auto', (req: Req, res: Res) => {
const db = getDb();
const rows = db.prepare(`
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.payment_source,
@ -136,7 +138,7 @@ router.get('/recent-auto', (req, res) => {
});
// GET /api/payments/:id
router.get('/:id', (req, res) => {
router.get('/:id', (req: Req, res: Res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
@ -144,7 +146,7 @@ router.get('/:id', (req, res) => {
});
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
router.post('/:id/undo-auto', (req, res) => {
router.post('/:id/undo-auto', (req: Req, res: Res) => {
const db = getDb();
const payment = db.prepare(`
SELECT p.* FROM payments p
@ -188,7 +190,7 @@ router.post('/:id/undo-auto', (req, res) => {
});
// POST /api/payments — create single payment
router.post('/', (req, res) => {
router.post('/', (req: Req, res: Res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure, allow_duplicate } = req.body;
@ -240,7 +242,7 @@ router.post('/', (req, res) => {
});
// POST /api/payments/quick — pay a bill (expected amount, today)
router.post('/quick', (req, res) => {
router.post('/quick', (req: Req, res: Res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
@ -296,7 +298,7 @@ router.post('/quick', (req, res) => {
});
// POST /api/payments/autopay-suggestions/:billId/confirm
router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
@ -369,7 +371,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
router.post('/autopay-suggestions/:billId/dismiss', (req, res) => {
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
@ -400,7 +402,7 @@ router.post('/autopay-suggestions/:billId/dismiss', (req, res) => {
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] }
router.post('/bulk', (req, res) => {
router.post('/bulk', (req: Req, res: Res) => {
const db = getDb();
const { payments } = req.body;
@ -477,7 +479,7 @@ router.post('/bulk', (req, res) => {
});
// PUT /api/payments/:id
router.put('/:id', (req, res) => {
router.put('/:id', (req: Req, res: Res) => {
const db = getDb();
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
@ -562,7 +564,7 @@ router.put('/:id', (req, res) => {
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req, res) => {
router.delete('/:id', (req: Req, res: Res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
@ -593,7 +595,7 @@ router.delete('/:id', (req, res) => {
});
// POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req, res) => {
router.post('/:id/restore', (req: Req, res: Res) => {
const db = getDb();
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
@ -629,7 +631,7 @@ router.post('/:id/restore', (req, res) => {
// Changes only the paid_date of a provider_sync payment to move it into the
// correct billing period when it posted just after month end.
// Does not touch the amount or balance_delta.
router.patch('/:id/attribute-to-month', (req, res) => {
router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
const db = getDb();
const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) {

View File

@ -1,10 +1,12 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
let pkg;
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
res.json({
name: 'Privacy',
version: pkg.version,

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -47,7 +49,7 @@ function profileResponse(user) {
// ── GET /api/profile ──────────────────────────────────────────────────────────
// Returns safe profile data for the signed-in user.
// Never returns password_hash, session tokens, or secrets.
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
const user = db.prepare(`
SELECT id, username, display_name, role, active, is_default_admin,
@ -81,7 +83,7 @@ router.get('/', (req, res) => {
// ── PATCH /api/profile ────────────────────────────────────────────────────────
// Updates safe profile fields: username and display_name.
// Ignores any unknown or restricted fields.
router.patch('/', (req, res) => {
router.patch('/', (req: Req, res: Res) => {
const { username } = req.body;
const displayNameInput = req.body.display_name !== undefined
? req.body.display_name
@ -142,7 +144,7 @@ router.patch('/', (req, res) => {
// ── GET /api/profile/settings ─────────────────────────────────────────────────
// Returns user-owned notification preferences from the users table.
// Does not return admin/global/SMTP settings.
router.get('/settings', (req, res) => {
router.get('/settings', (req: Req, res: Res) => {
const db = getDb();
const user = db.prepare(`
SELECT notification_email, notifications_enabled,
@ -179,7 +181,7 @@ router.get('/settings', (req, res) => {
// Cannot modify global/admin/SMTP settings through this endpoint.
const VALID_PUSH_CHANNELS = new Set(['ntfy', 'gotify', 'discord', 'telegram']);
router.patch('/settings', (req, res) => {
router.patch('/settings', (req: Req, res: Res) => {
const db = getDb();
const {
notification_email, email, notifications_enabled,
@ -269,7 +271,7 @@ router.patch('/settings', (req, res) => {
// Always requires: current_password, new_password, confirm_new_password.
// Never bypasses current_password verification regardless of must_change_password.
// Never accepts user_id from the request body.
router.post('/change-password', passwordLimiter, async (req, res) => {
router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) {
@ -332,7 +334,7 @@ router.post('/change-password', passwordLimiter, async (req, res) => {
// ── GET /api/profile/exports ──────────────────────────────────────────────────
// Returns metadata about available user export capabilities.
// Does not trigger any download; links point to the actual export endpoints.
router.get('/exports', (req, res) => {
router.get('/exports', (req: Req, res: Res) => {
res.json({
exports: [
{
@ -356,7 +358,7 @@ router.get('/exports', (req, res) => {
// ── GET /api/profile/import-history ──────────────────────────────────────────
// Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req, res) => {
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
try {
const history = getImportHistory(req.user.id);
res.json({ history });

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -5,12 +7,12 @@ const router = express.Router();
const { getUserSettings, setUserSettings } = require('../services/userSettings.cts');
// GET /api/settings — returns only user-facing app preferences
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
res.json(getUserSettings(req.user.id));
});
// PUT /api/settings — updates only allowed user-facing keys for this user
router.put('/', (req, res) => {
router.put('/', (req: Req, res: Res) => {
res.json(setUserSettings(req.user.id, req.body));
});

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
@ -84,13 +86,13 @@ function getDebtBills(userId, ramseyMode) {
}
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const ramseyMode = isRamseyMode(req.user.id);
res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill));
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => {
router.get('/settings', (req: Req, res: Res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({
@ -102,7 +104,7 @@ router.get('/settings', (req, res) => {
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => {
router.patch('/settings', (req: Req, res: Res) => {
const { extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund } = req.body;
let val = 0;
@ -149,7 +151,7 @@ router.patch('/settings', (req, res) => {
// GET /api/snowball/projection — snowball, avalanche, minimum-only projections
// Each debt result is enriched with current APR metrics (monthly interest, etc.)
router.get('/projection', (req, res) => {
router.get('/projection', (req: Req, res: Res) => {
const db = getDb();
const ramseyMode = isRamseyMode(req.user.id);
@ -227,7 +229,7 @@ function buildComparison(snowball, minimum_only) {
}
// PATCH /api/snowball/order — batch-save snowball_order positions
router.patch('/order', (req, res) => {
router.patch('/order', (req: Req, res: Res) => {
const items = req.body;
if (!Array.isArray(items)) {
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
@ -308,7 +310,7 @@ function enrichPlanWithProgress(db, plan) {
}
// POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req, res) => {
router.post('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const userId = req.user.id;
@ -385,7 +387,7 @@ router.post('/plans', (req, res) => {
});
// GET /api/snowball/plans — list all plans for user
router.get('/plans', (req, res) => {
router.get('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const plans = db.prepare(`
@ -399,7 +401,7 @@ router.get('/plans', (req, res) => {
});
// GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req, res) => {
router.get('/plans/active', (req: Req, res: Res) => {
try {
const db = getDb();
const plan = db.prepare(`
@ -415,7 +417,7 @@ router.get('/plans/active', (req, res) => {
});
// PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req, res) => {
router.patch('/plans/:id', (req: Req, res: Res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
@ -465,13 +467,13 @@ function transitionPlan(req, res, { allowedFrom, setSql, action, past }) {
}
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon}
router.post('/plans/:id/pause', (req, res) =>
router.post('/plans/:id/pause', (req: Req, res: Res) =>
transitionPlan(req, res, { allowedFrom: ['active'], setSql: "status = 'paused', paused_at = datetime('now')", action: 'pause', past: 'paused' }));
router.post('/plans/:id/resume', (req, res) =>
router.post('/plans/:id/resume', (req: Req, res: Res) =>
transitionPlan(req, res, { allowedFrom: ['paused'], setSql: "status = 'active', paused_at = NULL", action: 'resume', past: 'resumed' }));
router.post('/plans/:id/complete', (req, res) =>
router.post('/plans/:id/complete', (req: Req, res: Res) =>
transitionPlan(req, res, { allowedFrom: ['active', 'paused'], setSql: "status = 'completed', completed_at = datetime('now')", action: 'complete', past: 'completed' }));
router.post('/plans/:id/abandon', (req, res) =>
router.post('/plans/:id/abandon', (req: Req, res: Res) =>
transitionPlan(req, res, { allowedFrom: ['active', 'paused'], setSql: "status = 'abandoned'", action: 'abandon', past: 'abandoned' }));
module.exports = router;

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -20,7 +22,7 @@ function parseYM(source) {
}
// GET /api/spending/summary?year=&month=
router.get('/summary', (req, res) => {
router.get('/summary', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
@ -32,7 +34,7 @@ router.get('/summary', (req, res) => {
});
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req, res) => {
router.get('/transactions', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
@ -55,7 +57,7 @@ router.get('/transactions', (req, res) => {
});
// PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req, res) => {
router.patch('/transactions/:id/category', (req: Req, res: Res) => {
const txId = parseInt(req.params.id, 10);
if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' });
@ -71,7 +73,7 @@ router.patch('/transactions/:id/category', (req, res) => {
});
// GET /api/spending/budgets?year=&month=
router.get('/budgets', (req, res) => {
router.get('/budgets', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
@ -83,7 +85,7 @@ router.get('/budgets', (req, res) => {
});
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req, res) => {
router.post('/budgets/copy', (req: Req, res: Res) => {
const ym = parseYM(req.body || {});
if (ym.error) return res.status(400).json({ error: ym.error });
@ -122,7 +124,7 @@ router.post('/budgets/copy', (req, res) => {
});
// PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req, res) => {
router.put('/budgets', (req: Req, res: Res) => {
const { category_id, year, month, amount } = req.body || {};
if (!category_id) return res.status(400).json({ error: 'category_id required' });
const ym = parseYM({ year, month });
@ -138,7 +140,7 @@ router.put('/budgets', (req, res) => {
});
// GET /api/spending/category-rules
router.get('/category-rules', (req, res) => {
router.get('/category-rules', (req: Req, res: Res) => {
try {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
} catch (err) {
@ -148,7 +150,7 @@ router.get('/category-rules', (req, res) => {
});
// POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req, res) => {
router.post('/category-rules', (req: Req, res: Res) => {
const { category_id, merchant } = req.body || {};
if (!category_id || !merchant) return res.status(400).json({ error: 'category_id and merchant required' });
try {
@ -161,7 +163,7 @@ router.post('/category-rules', (req, res) => {
});
// DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req, res) => {
router.delete('/category-rules/:id', (req: Req, res: Res) => {
try {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
@ -172,7 +174,7 @@ router.delete('/category-rules/:id', (req, res) => {
});
// GET /api/spending/income?year=&month=&page=
router.get('/income', (req, res) => {
router.get('/income', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const fs = require('fs');
@ -48,7 +50,7 @@ function monthRange(now) {
}
// GET /api/status
router.get('/', async (req, res) => {
router.get('/', async (req: Req, res: Res) => {
const runtimeState = getStatusRuntime();
const now = new Date();
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
@ -16,7 +18,7 @@ const {
} = require('../services/subscriptionService.cts');
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService.cts');
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const subscriptions = getSubscriptions(db, req.user.id);
@ -26,14 +28,14 @@ router.get('/', (req, res) => {
});
});
router.get('/recommendations', (req, res) => {
router.get('/recommendations', (req: Req, res: Res) => {
const db = getDb();
res.json({
recommendations: getSubscriptionRecommendations(db, req.user.id),
});
});
router.get('/transaction-matches', (req, res) => {
router.get('/transaction-matches', (req: Req, res: Res) => {
try {
res.json({
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
@ -43,7 +45,7 @@ router.get('/transaction-matches', (req, res) => {
}
});
router.post('/recommendations/decline', (req, res) => {
router.post('/recommendations/decline', (req: Req, res: Res) => {
const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
return res.status(400).json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
@ -66,7 +68,7 @@ router.post('/recommendations/decline', (req, res) => {
// POST /api/subscriptions/recommendations/match-bill
// Link an existing bill to all transactions in a recommendation (no new bill created).
router.post('/recommendations/match-bill', (req, res) => {
router.post('/recommendations/match-bill', (req: Req, res: Res) => {
const billId = parseInt(req.body?.bill_id, 10);
const rawIds = Array.isArray(req.body?.transaction_ids) ? req.body.transaction_ids : [];
const txIds = rawIds.map(id => parseInt(id, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 50);
@ -136,7 +138,7 @@ router.post('/recommendations/match-bill', (req, res) => {
res.json({ ok: true, matched_count: matchedCount + autoMatched, bill_name: bill.name });
});
router.post('/recommendations/create', (req, res) => {
router.post('/recommendations/create', (req: Req, res: Res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
if (req.body?.category_id) {
@ -168,7 +170,7 @@ router.post('/recommendations/create', (req, res) => {
// ── Catalog browser ───────────────────────────────────────────────────────────
router.get('/catalog', (req, res) => {
router.get('/catalog', (req: Req, res: Res) => {
const db = getDb();
try {
const catalogEntries = db.prepare(`
@ -229,7 +231,7 @@ router.get('/catalog', (req, res) => {
});
// Update which catalog entry a bill is linked to (or unlink with null)
router.put('/:id/catalog-link', (req, res) => {
router.put('/:id/catalog-link', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) {
@ -275,7 +277,7 @@ router.put('/:id/catalog-link', (req, res) => {
});
// Add a custom bank descriptor for a catalog entry (per-user)
router.post('/catalog/:catalogId/descriptors', (req, res) => {
router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
const db = getDb();
const catalogId = parseInt(req.params.catalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) {
@ -319,7 +321,7 @@ router.post('/catalog/:catalogId/descriptors', (req, res) => {
});
// Delete a user-added catalog descriptor
router.delete('/catalog/descriptors/:id', (req, res) => {
router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
const db = getDb();
const descriptorId = parseInt(req.params.id, 10);
if (!Number.isInteger(descriptorId) || descriptorId < 1) {
@ -350,7 +352,7 @@ router.delete('/catalog/descriptors/:id', (req, res) => {
}
});
router.patch('/:id', (req, res) => {
router.patch('/:id', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
@ -392,7 +394,7 @@ function buildSummary(db, userId, year, month) {
};
}
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
@ -400,7 +402,7 @@ router.get('/', (req, res) => {
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
});
router.put('/income', (req, res) => {
router.put('/income', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });

View File

@ -1,10 +1,12 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService.cts');
const { standardizeError } = require('../middleware/errorFormatter');
// GET /api/tracker/overdue-count — lightweight count for sidebar badge
router.get('/overdue-count', (req, res) => {
router.get('/overdue-count', (req: Req, res: Res) => {
try {
res.json(getOverdueCount(req.user.id));
} catch (err) {
@ -14,7 +16,7 @@ router.get('/overdue-count', (req, res) => {
});
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
try {
const result = getTracker(req.user.id, req.query);
if (result.error) {
@ -28,7 +30,7 @@ router.get('/', (req, res) => {
});
// GET /api/tracker/upcoming?days=30
router.get('/upcoming', (req, res) => {
router.get('/upcoming', (req: Req, res: Res) => {
try {
res.json(getUpcomingBills(req.user.id, req.query));
} catch (err) {

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
@ -358,7 +360,7 @@ function bankLedgerWhere(query, userId) {
}
// GET /api/transactions
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const db = getDb();
ensureManualDataSource(db, req.user.id);
@ -486,7 +488,7 @@ router.get('/', (req, res) => {
});
// GET /api/transactions/bank-ledger
router.get('/bank-ledger', (req, res) => {
router.get('/bank-ledger', (req: Req, res: Res) => {
const config = getBankSyncConfig();
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
@ -609,7 +611,7 @@ router.get('/bank-ledger', (req, res) => {
});
// POST /api/transactions/manual
router.post('/manual', (req, res) => {
router.post('/manual', (req: Req, res: Res) => {
const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
@ -650,7 +652,7 @@ router.post('/manual', (req, res) => {
});
// PUT /api/transactions/:id
router.put('/:id', (req, res) => {
router.put('/:id', (req: Req, res: Res) => {
const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
@ -704,7 +706,7 @@ router.put('/:id', (req, res) => {
});
// DELETE /api/transactions/:id
router.delete('/:id', (req, res) => {
router.delete('/:id', (req: Req, res: Res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
@ -721,7 +723,7 @@ router.delete('/:id', (req, res) => {
});
// POST /api/transactions/:id/match
router.post('/:id/match', (req, res) => {
router.post('/:id/match', (req: Req, res: Res) => {
try {
const result = matchTransactionToBill(
req.user.id,
@ -736,7 +738,7 @@ router.post('/:id/match', (req, res) => {
});
// POST /api/transactions/:id/unmatch
router.post('/:id/unmatch', (req, res) => {
router.post('/:id/unmatch', (req: Req, res: Res) => {
try {
const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result);
@ -749,7 +751,7 @@ router.post('/:id/unmatch', (req, res) => {
// Body: { matches: [{ transaction_id, payment_id, payment_source }] }
// Handles both provider_sync (full undo: balance restore + delete payment) and
// transaction_match (standard service unmatch) in one call.
router.post('/unmatch-bulk', (req, res) => {
router.post('/unmatch-bulk', (req: Req, res: Res) => {
const matches = req.body?.matches;
if (!Array.isArray(matches) || matches.length === 0) {
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
@ -816,7 +818,7 @@ router.post('/unmatch-bulk', (req, res) => {
});
// POST /api/transactions/:id/ignore
router.post('/:id/ignore', (req, res) => {
router.post('/:id/ignore', (req: Req, res: Res) => {
try {
const result = ignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction);
@ -826,7 +828,7 @@ router.post('/:id/ignore', (req, res) => {
});
// POST /api/transactions/:id/unignore
router.post('/:id/unignore', (req, res) => {
router.post('/:id/unignore', (req: Req, res: Res) => {
try {
const result = unignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction);
@ -836,7 +838,7 @@ router.post('/:id/unignore', (req, res) => {
});
// GET /api/transactions/:id/merchant-match
router.get('/:id/merchant-match', (req, res) => {
router.get('/:id/merchant-match', (req: Req, res: Res) => {
try {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
@ -853,7 +855,7 @@ router.get('/:id/merchant-match', (req, res) => {
});
// POST /api/transactions/:id/apply-merchant-match
router.post('/:id/apply-merchant-match', (req, res) => {
router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
try {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
@ -879,7 +881,7 @@ router.post('/:id/apply-merchant-match', (req, res) => {
});
// POST /api/transactions/auto-categorize
router.post('/auto-categorize', (req, res) => {
router.post('/auto-categorize', (req: Req, res: Res) => {
try {
const db = getDb();
const result = applyMerchantStoreMatches(db, req.user.id, { dryRun: Boolean(req.body?.dry_run) });

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
'use strict';
const express = require('express');
@ -9,7 +11,7 @@ const { eraseUserData } = require('../services/userDataService.cts');
const { standardizeError } = require('../middleware/errorFormatter');
// GET /api/user/seeded-status — returns whether the current user has any seeded data
router.get('/seeded-status', (req, res) => {
router.get('/seeded-status', (req: Req, res: Res) => {
try {
const db = getDb();
const userId = req.user.id;
@ -36,7 +38,7 @@ router.get('/seeded-status', (req, res) => {
});
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
router.post('/clear-demo-data', demoDataLimiter, (req: Req, res: Res) => {
try {
const db = getDb();
const userId = req.user.id;
@ -67,7 +69,7 @@ router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
});
// POST /api/user/seed-demo-data — seeds demo bills for the requesting user
router.post('/seed-demo-data', (req, res) => {
router.post('/seed-demo-data', (req: Req, res: Res) => {
try {
const result = seedDemoData(req.user.id);
res.json({
@ -85,7 +87,7 @@ router.post('/seed-demo-data', (req, res) => {
// POST /api/user/erase-data — permanently wipe the requesting user's financial +
// imported data (bills, payments, transactions, connections, categories, imports).
// Preserves the account, login, 2FA, and preferences. Requires a type-to-confirm token.
router.post('/erase-data', demoDataLimiter, (req, res) => {
router.post('/erase-data', demoDataLimiter, (req: Req, res: Res) => {
const confirm = String(req.body?.confirm ?? '').trim().toUpperCase();
if (confirm !== 'ERASE') {
return res.status(400).json(standardizeError('Type ERASE to confirm.', 'ERASE_CONFIRM_REQUIRED', 'confirm'));

View File

@ -1,3 +1,5 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
const fs = require('fs');
@ -42,13 +44,13 @@ function parseHistory() {
}
// GET /api/version — version always comes from package.json; notes from HISTORY.md
router.get('/', (req, res) => {
router.get('/', (req: Req, res: Res) => {
const { notes } = parseHistory();
res.json({ version: pkg.version, notes });
});
// GET /api/version/history
router.get('/history', (req, res) => {
router.get('/history', (req: Req, res: Res) => {
try {
if (!fs.existsSync(HISTORY_PATH)) {
return res.status(404).json({
@ -80,7 +82,7 @@ router.get('/history', (req, res) => {
// GET /api/version/update-status — public, returns cached update check (no force-refresh)
const { checkForUpdates } = require('../services/updateCheckService.cts');
router.get('/update-status', async (req, res) => {
router.get('/update-status', async (req: Req, res: Res) => {
try {
res.json(await checkForUpdates(false));
} catch (err) {

View File

@ -106,47 +106,47 @@ app.use('/api/auth/login', skipRateLimitIfNoUsers(loginUsernameLimiter));
// changes, require the SPA's x-csrf-token header like other mutating requests.
// All other auth routes require CSRF (loginLimiter applied via /api/auth/login above)
// Note: passwordLimiter is applied individually on routes that actually change passwords
app.use('/api/auth', csrfMiddleware, require('./routes/auth'));
app.use('/api/auth', csrfMiddleware, require('./routes/auth.cts'));
// OIDC — rate-limited; returns 501 gracefully when OIDC is not configured
app.use('/api/auth/oidc', csrfMiddleware, oidcLimiter, require('./routes/authOidc'));
app.use('/api/auth/oidc', csrfMiddleware, oidcLimiter, require('./routes/authOidc.cts'));
// Admin — all routes already require auth+admin; mutation-heavy routes get
// an additional per-IP rate limit applied to the whole admin namespace
// Backup operations have dedicated rate limiting (5 per hour) to prevent resource exhaustion
// NOTE: backupOperationLimiter is applied per-route in routes/admin.js to avoid blocking non-backup admin actions
app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminActionLimiter, require('./routes/admin'));
app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminActionLimiter, require('./routes/admin.cts'));
app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker'));
app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills'));
app.use('/api/subscriptions', csrfMiddleware, requireAuth, requireUser, require('./routes/subscriptions'));
app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments'));
app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources'));
app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions'));
app.use('/api/matches', csrfMiddleware, requireAuth, requireUser, require('./routes/matches'));
app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories'));
app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings'));
app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user'));
app.use('/api/calendar', require('./routes/calendarFeed'));
app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require('./routes/calendar'));
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/spending', csrfMiddleware, requireAuth, requireUser, require('./routes/spending'));
app.use('/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball'));
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public
app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only
app.use('/api/privacy', require('./routes/privacy')); // public
app.use('/api/version', require('./routes/version')); // public
app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker.cts'));
app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills.cts'));
app.use('/api/subscriptions', csrfMiddleware, requireAuth, requireUser, require('./routes/subscriptions.cts'));
app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments.cts'));
app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources.cts'));
app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions.cts'));
app.use('/api/matches', csrfMiddleware, requireAuth, requireUser, require('./routes/matches.cts'));
app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories.cts'));
app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings.cts'));
app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user.cts'));
app.use('/api/calendar', require('./routes/calendarFeed.cts'));
app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require('./routes/calendar.cts'));
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary.cts'));
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts.cts'));
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics.cts'));
app.use('/api/spending', csrfMiddleware, requireAuth, requireUser, require('./routes/spending.cts'));
app.use('/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball.cts'));
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications.cts'));
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status.cts'));
app.use('/api/about', require('./routes/about.cts')); // public
app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin.cts')); // admin-only
app.use('/api/privacy', require('./routes/privacy.cts')); // public
app.use('/api/version', require('./routes/version.cts')); // public
// Profile — rate limit only on password-change, not all profile reads
app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile'));
app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile.cts'));
// Export / Import — per-IP rate limited to deter abuse and resource exhaustion
const importRoutes = require('./routes/import');
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export'));
const importRoutes = require('./routes/import.cts');
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export.cts'));
app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes);
app.use('/api/imports', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes);

View File

@ -25,7 +25,7 @@ function createBill(db, userId, name, dueDay) {
}
function callBillsRoute(routePath, method, { userId, params = {}, query = {}, body = {} }) {
const billsRouter = require('../routes/bills');
const billsRouter = require('../routes/bills.cts');
const layer = billsRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;

View File

@ -33,7 +33,7 @@ function insertBill(db, userId, name, daysAgo) {
}
function callGetDeleted(userId) {
const router = require('../routes/bills');
const router = require('../routes/bills.cts');
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;

View File

@ -45,7 +45,7 @@ function createBill(db, userId, overrides = {}) {
}
function callFeedRoute(query) {
const router = require('../routes/calendarFeed');
const router = require('../routes/calendarFeed.cts');
const layer = router.stack.find(item => item.route?.path === '/feed.ics' && item.route.methods.get);
assert.ok(layer, 'GET /feed.ics route should exist');
const handler = layer.route.stack[0].handle;

View File

@ -23,7 +23,7 @@ function createCategory(db, userId, name) {
}
function callCategoriesRoute(routePath, method, { userId, params = {}, body = {} }) {
const categoriesRouter = require('../routes/categories');
const categoriesRouter = require('../routes/categories.cts');
const layer = categoriesRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;

View File

@ -24,7 +24,7 @@ function createCategory(db, userId, name) {
}
function callCategoriesRoute(routePath, method, { userId, params = {}, body = {} }) {
const categoriesRouter = require('../routes/categories');
const categoriesRouter = require('../routes/categories.cts');
const layer = categoriesRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;

View File

@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-roundtrip-test-${process.pid
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { buildUserDbExportFile } = require('../routes/export');
const { buildUserDbExportFile } = require('../routes/export.cts');
const { previewUserDbImport, applyUserDbImport } = require('../services/userDbImportService.cts');
const mkUser = (db, name) =>

View File

@ -12,7 +12,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-export-richer-${process.pid}
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const exportRouter = require('../routes/export');
const exportRouter = require('../routes/export.cts');
const { getUserExportData } = exportRouter;
let userId, billId;

View File

@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-quickpay-${process.pid}.sqli
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const router = require('../routes/payments');
const router = require('../routes/payments.cts');
function handler(method, routePath) {
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);

View File

@ -15,7 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const router = require('../routes/payments');
const router = require('../routes/payments.cts');
function handler(method, routePath) {
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);

View File

@ -18,7 +18,7 @@ function createUser(db, suffix) {
}
function callProfileRoute(method, { userId, body = {} }) {
const profileRouter = require('../routes/profile');
const profileRouter = require('../routes/profile.cts');
const layer = profileRouter.stack.find(item => item.route?.path === '/' && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} / should exist`);
const handler = layer.route.stack[0].handle;

View File

@ -21,7 +21,7 @@ const { getDb, closeDb } = require('../db/database');
const { getTracker } = require('../services/trackerService.cts');
const { getAnalyticsSummary } = require('../services/analyticsService.cts');
const { resolveDueDate } = require('../services/statusService.cts');
const summaryRouter = require('../routes/summary');
const summaryRouter = require('../routes/summary.cts');
// Fixed "now" so occurrence gating is deterministic: June 20, 2026.
const NOW = new Date(2026, 5, 20, 12, 0, 0);

View File

@ -13,7 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const router = require('../routes/snowball');
const router = require('../routes/snowball.cts');
function handler(method, routePath) {
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);

View File

@ -15,7 +15,7 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function callSummary(userId, year, month) {
const router = require('../routes/summary');
const router = require('../routes/summary.cts');
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
assert.ok(layer, 'GET /api/summary route should exist');
const handler = layer.route.stack[0].handle;

View File

@ -15,7 +15,7 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function callSummary(userId, year, month) {
const router = require('../routes/summary');
const router = require('../routes/summary.cts');
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
const handler = layer.route.stack[0].handle;
return new Promise((resolve) => {

View File

@ -125,7 +125,7 @@ test('auto_mark_paid bill creates an autopay payment and drops the balance atomi
});
test('GET /tracker returns a standardized error for an invalid month', async () => {
const router = require('../routes/tracker');
const router = require('../routes/tracker.cts');
const layer = router.stack.find(l => l.route?.path === '/' && l.route.methods.get);
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
const userId = mkUser('t1-route');

View File

@ -85,7 +85,7 @@ function trackerRow(userId, billId, today = '2026-05-20') {
}
function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
const billsRouter = require('../routes/bills');
const billsRouter = require('../routes/bills.cts');
const layer = billsRouter.stack.find(item => item.route?.path === routePath && item.route.methods.get);
assert.ok(layer, `route ${routePath} should exist`);
const handler = layer.route.stack[0].handle;
@ -115,7 +115,7 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
}
function callPaymentsRoute(routePath, method, { userId, params = {}, query = {}, body = {} }) {
const paymentsRouter = require('../routes/payments');
const paymentsRouter = require('../routes/payments.cts');
const layer = paymentsRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;