2026-07-06 11:26:50 -05:00
// @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' ;
2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
2026-07-06 16:05:18 -05:00
const { log } = require ( '../utils/logger.cts' ) ;
2026-07-06 14:23:53 -05:00
const router = express . Router ( ) ;
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { getDb , rollbackMigration } = require ( '../db/database.cts' ) ;
2026-07-06 14:23:53 -05:00
const {
getBankSyncConfig ,
setBankSyncEnabled ,
setSyncIntervalHours ,
setSyncDays ,
setDebugLogging ,
} = require ( '../services/bankSyncConfigService.cts' ) ;
2026-07-06 11:00:01 -05:00
const { getStatus : getBankSyncWorkerStatus } = require ( '../services/bankSyncWorker.cts' ) ;
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { isEnvKeyActive , keyFingerprint } = require ( '../services/encryptionService.cts' ) ;
2026-07-06 11:06:05 -05:00
const { hashPassword } = require ( '../services/authService.cts' ) ;
2026-07-06 10:47:16 -05:00
const { logAudit } = require ( '../services/auditService.cts' ) ;
2026-05-03 19:51:57 -05:00
const {
createBackup ,
deleteBackup ,
getBackupFile ,
importBackupBuffer ,
listBackups ,
restoreBackup ,
2026-07-06 11:06:05 -05:00
} = require ( '../services/backupService.cts' ) ;
2026-05-03 19:51:57 -05:00
const {
getScheduleStatus ,
runScheduledBackupNow ,
saveSettings : saveBackupScheduleSettings ,
2026-07-06 11:00:01 -05:00
} = require ( '../services/backupScheduler.cts' ) ;
2026-05-03 19:51:57 -05:00
const {
getCleanupStatus ,
runAllCleanup ,
validateAndApplySettings : applyCleanupSettings ,
2026-07-06 11:06:05 -05:00
} = require ( '../services/cleanupService.cts' ) ;
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { backupOperationLimiter } = require ( '../middleware/rateLimiter.cts' ) ;
2026-05-03 19:51:57 -05:00
// All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level)
2026-06-03 20:28:37 -05:00
// Returns a validated positive integer from req.params.id, or null.
function parseUserId ( params ) {
const n = parseInt ( params . id , 10 ) ;
return Number . isInteger ( n ) && n > 0 ? n : null ;
}
2026-05-03 19:51:57 -05:00
function sendError ( res , err ) {
const status = err . status || 500 ;
res . status ( status ) . json ( { error : status === 500 ? 'Backup operation failed' : err . message } ) ;
}
// GET /api/admin/has-users
2026-07-06 11:26:50 -05:00
router . get ( '/has-users' , ( req : Req , res : Res ) = > {
2026-05-04 23:34:24 -05:00
const count = getDb ( ) . prepare ( 'SELECT COUNT(*) AS n FROM users WHERE id != ?' ) . get ( req . user . id ) . n ;
2026-05-03 19:51:57 -05:00
res . json ( { has_users : count > 0 } ) ;
} ) ;
// GET /api/admin/users
2026-07-06 11:26:50 -05:00
router . get ( '/users' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
res . json (
2026-07-06 14:23:53 -05:00
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' ,
)
. all ( ) ,
2026-05-03 19:51:57 -05:00
) ;
} ) ;
// POST /api/admin/backups
2026-07-06 11:26:50 -05:00
router . post ( '/backups' , backupOperationLimiter , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
const backup = await createBackup ( ) ;
res . status ( 201 ) . json ( backup ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// GET /api/admin/backups
2026-07-06 11:26:50 -05:00
router . get ( '/backups' , backupOperationLimiter , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( { backups : listBackups ( ) } ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// GET /api/admin/backups/settings
2026-07-06 11:26:50 -05:00
router . get ( '/backups/settings' , backupOperationLimiter , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( getScheduleStatus ( ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// PUT /api/admin/backups/settings
2026-07-06 11:26:50 -05:00
router . put ( '/backups/settings' , backupOperationLimiter , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( saveBackupScheduleSettings ( req . body ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// POST /api/admin/backups/run-scheduled-now
2026-07-06 11:26:50 -05:00
router . post ( '/backups/run-scheduled-now' , backupOperationLimiter , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . status ( 201 ) . json ( await runScheduledBackupNow ( ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// POST /api/admin/backups/import
router . post (
'/backups/import' ,
2026-05-09 13:03:36 -05:00
backupOperationLimiter ,
2026-05-03 19:51:57 -05:00
express . raw ( {
type : [ 'application/octet-stream' , 'application/x-sqlite3' , 'application/vnd.sqlite3' ] ,
limit : '100mb' ,
} ) ,
2026-07-06 11:26:50 -05:00
async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
2026-05-09 13:03:36 -05:00
// Extract expected checksum from request headers or query
const expectedChecksum = req . headers [ 'x-checksum-sha256' ] || req . query . checksum ;
2026-07-06 14:23:53 -05:00
2026-05-09 13:03:36 -05:00
const backup = await importBackupBuffer ( req . body , {
expectedChecksum : expectedChecksum ? String ( expectedChecksum ) . trim ( ) : undefined ,
} ) ;
2026-05-03 19:51:57 -05:00
res . status ( 201 ) . json ( backup ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ,
) ;
// GET /api/admin/backups/:id/download
2026-07-06 11:26:50 -05:00
router . get ( '/backups/:id/download' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
const backup = getBackupFile ( req . params . id ) ;
res . setHeader ( 'Content-Type' , 'application/octet-stream' ) ;
res . setHeader ( 'X-Content-Type-Options' , 'nosniff' ) ;
res . download ( backup . path , backup . metadata . id , ( err ) = > {
if ( err && ! res . headersSent ) sendError ( res , err ) ;
} ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// POST /api/admin/backups/:id/restore
2026-07-06 11:26:50 -05:00
router . post ( '/backups/:id/restore' , backupOperationLimiter , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( await restoreBackup ( req . params . id ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// DELETE /api/admin/backups/:id
2026-07-06 11:26:50 -05:00
router . delete ( '/backups/:id' , backupOperationLimiter , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( deleteBackup ( req . params . id ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// POST /api/admin/users
2026-07-06 11:26:50 -05:00
router . post ( '/users' , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const { username , password } = req . body ;
if ( ! username || username . length < 3 )
return res . status ( 400 ) . json ( { error : 'Username must be at least 3 characters' } ) ;
if ( ! password || password . length < 8 )
return res . status ( 400 ) . json ( { error : 'Password must be at least 8 characters' } ) ;
const db = getDb ( ) ;
if ( db . prepare ( 'SELECT id FROM users WHERE username = ?' ) . get ( username ) )
return res . status ( 409 ) . json ( { error : 'Username already taken' } ) ;
2026-05-31 15:06:10 -05:00
try {
2026-07-06 14:23:53 -05:00
const hash = await hashPassword ( password ) ;
const result = db
. prepare (
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)" ,
)
. run ( username , hash ) ;
const created = db
. prepare (
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ,
)
. get ( result . lastInsertRowid ) ;
2026-05-31 15:06:10 -05:00
logAudit ( {
2026-07-06 14:23:53 -05:00
user_id : req.user.id ,
action : 'admin.user.create' ,
entity_type : 'user' ,
entity_id : created.id ,
2026-05-31 15:06:10 -05:00
details : { created_username : username } ,
2026-07-06 14:23:53 -05:00
ip_address : req.ip ,
user_agent : req.get ( 'user-agent' ) ,
2026-05-31 15:06:10 -05:00
} ) ;
2026-05-28 03:59:35 -05:00
2026-05-31 15:06:10 -05:00
res . status ( 201 ) . json ( created ) ;
} catch ( err ) {
2026-07-06 16:05:18 -05:00
log . error ( '[admin] create-user error:' , err . message ) ;
2026-05-31 15:06:10 -05:00
res . status ( 500 ) . json ( { error : 'Failed to create user' } ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
// PUT /api/admin/users/:id/password
2026-07-06 11:26:50 -05:00
router . put ( '/users/:id/password' , async ( req : Req , res : Res ) = > {
2026-06-03 20:28:37 -05:00
const targetId = parseUserId ( req . params ) ;
if ( ! targetId ) return res . status ( 400 ) . json ( { error : 'Invalid user ID' } ) ;
2026-05-03 19:51:57 -05:00
const { password } = req . body ;
if ( ! password || password . length < 8 )
return res . status ( 400 ) . json ( { error : 'Password must be at least 8 characters' } ) ;
2026-07-06 14:23:53 -05:00
const db = getDb ( ) ;
2026-06-03 20:28:37 -05:00
const user = db . prepare ( 'SELECT * FROM users WHERE id = ?' ) . get ( targetId ) ;
2026-05-31 15:06:10 -05:00
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
2026-05-28 03:59:35 -05:00
2026-05-31 15:06:10 -05:00
try {
const hash = await hashPassword ( password ) ;
2026-07-06 14:23:53 -05:00
db . prepare (
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?" ,
) . run ( hash , targetId ) ;
2026-06-03 20:28:37 -05:00
db . prepare ( 'DELETE FROM sessions WHERE user_id = ?' ) . run ( targetId ) ;
2026-05-31 15:06:10 -05:00
logAudit ( {
2026-07-06 14:23:53 -05:00
user_id : req.user.id ,
action : 'admin.password.reset' ,
entity_type : 'user' ,
entity_id : targetId ,
2026-05-31 15:06:10 -05:00
details : { target_username : user.username } ,
2026-07-06 14:23:53 -05:00
ip_address : req.ip ,
user_agent : req.get ( 'user-agent' ) ,
2026-05-31 15:06:10 -05:00
} ) ;
2026-05-28 03:59:35 -05:00
2026-05-31 15:06:10 -05:00
res . json ( { success : true } ) ;
} catch ( err ) {
2026-07-06 16:05:18 -05:00
log . error ( '[admin] reset-password error:' , err . message ) ;
2026-05-31 15:06:10 -05:00
res . status ( 500 ) . json ( { error : 'Failed to reset password' } ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
// PUT /api/admin/users/:id/role
// Promote/demote an existing user. Prevents removing the last admin or
// changing your own role mid-session.
2026-07-06 11:26:50 -05:00
router . put ( '/users/:id/role' , ( req : Req , res : Res ) = > {
2026-06-03 20:28:37 -05:00
const targetId = parseUserId ( req . params ) ;
if ( ! targetId ) return res . status ( 400 ) . json ( { error : 'Invalid user ID' } ) ;
2026-05-03 19:51:57 -05:00
const { role } = req . body ;
if ( ! [ 'admin' , 'user' ] . includes ( role ) ) {
return res . status ( 400 ) . json ( { error : 'role must be "admin" or "user"' } ) ;
}
2026-07-06 14:23:53 -05:00
const db = getDb ( ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE id = ?' ) . get ( targetId ) ;
2026-05-03 19:51:57 -05:00
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
if ( req . user ? . id === targetId ) {
return res . status ( 400 ) . json ( { error : 'You cannot change your own admin role.' } ) ;
}
if ( user . role === 'admin' && role === 'user' ) {
const adminCount = db . prepare ( "SELECT COUNT(*) AS n FROM users WHERE role = 'admin'" ) . get ( ) . n ;
if ( adminCount <= 1 ) {
return res . status ( 400 ) . json ( { error : 'Cannot remove the last admin account.' } ) ;
}
}
2026-05-09 13:03:36 -05:00
// SECURITY FIX (2026-05-08): Delete all sessions for the target user when role changes.
// This forces re-authentication with the new role, preventing session hijacking
// from being used to bypass privilege checks.
db . prepare ( 'DELETE FROM sessions WHERE user_id = ?' ) . run ( targetId ) ;
2026-05-10 00:03:12 -05:00
const previousRole = user . role ;
2026-07-06 14:23:53 -05:00
db . prepare ( "UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?" ) . run (
role ,
targetId ,
) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
logAudit ( {
user_id : req.user.id ,
action : 'role.change' ,
entity_type : 'user' ,
entity_id : targetId ,
details : { old_role : previousRole , new_role : role } ,
ip_address : req.ip ,
user_agent : req.get ( 'user-agent' ) ,
} ) ;
2026-05-10 00:03:12 -05:00
2026-07-06 14:23:53 -05:00
const updated = db
. prepare (
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ,
)
. get ( targetId ) ;
2026-05-03 19:51:57 -05:00
res . json ( updated ) ;
} ) ;
2026-05-04 23:34:24 -05:00
// PUT /api/admin/users/:id/active
2026-07-06 11:26:50 -05:00
router . put ( '/users/:id/active' , ( req : Req , res : Res ) = > {
2026-06-03 20:28:37 -05:00
const targetId = parseUserId ( req . params ) ;
if ( ! targetId ) return res . status ( 400 ) . json ( { error : 'Invalid user ID' } ) ;
2026-05-04 23:34:24 -05:00
const active = req . body ? . active ? 1 : 0 ;
const db = getDb ( ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE id = ?' ) . get ( targetId ) ;
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
if ( req . user ? . id === targetId ) {
return res . status ( 400 ) . json ( { error : 'You cannot deactivate your own account.' } ) ;
}
2026-07-06 14:23:53 -05:00
db . prepare ( "UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?" ) . run (
active ,
targetId ,
) ;
2026-05-04 23:34:24 -05:00
if ( ! active ) db . prepare ( 'DELETE FROM sessions WHERE user_id = ?' ) . run ( targetId ) ;
2026-07-06 14:23:53 -05:00
res . json (
db
. prepare (
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ,
)
. get ( targetId ) ,
) ;
2026-05-04 23:34:24 -05:00
} ) ;
2026-05-14 01:17:05 -05:00
// PUT /api/admin/users/:id/username
2026-07-06 11:26:50 -05:00
router . put ( '/users/:id/username' , ( req : Req , res : Res ) = > {
2026-05-14 01:17:05 -05:00
const { username } = req . body ;
if ( ! username || typeof username !== 'string' ) {
return res . status ( 400 ) . json ( { error : 'username is required' } ) ;
}
const trimmed = username . trim ( ) ;
if ( trimmed . length < 3 ) {
return res . status ( 400 ) . json ( { error : 'Username must be at least 3 characters' } ) ;
}
if ( trimmed . length > 50 ) {
return res . status ( 400 ) . json ( { error : 'Username must be 50 characters or fewer' } ) ;
}
2026-06-03 20:28:37 -05:00
const targetId = parseUserId ( req . params ) ;
if ( ! targetId ) return res . status ( 400 ) . json ( { error : 'Invalid user ID' } ) ;
2026-07-06 14:23:53 -05:00
const db = getDb ( ) ;
const user = db . prepare ( 'SELECT id, username FROM users WHERE id = ?' ) . get ( targetId ) ;
2026-05-14 01:17:05 -05:00
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
2026-07-06 14:23:53 -05:00
const taken = db
. prepare ( 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?' )
. get ( trimmed , targetId ) ;
2026-05-14 01:17:05 -05:00
if ( taken ) return res . status ( 409 ) . json ( { error : 'Username already taken' } ) ;
const previousUsername = user . username ;
2026-07-06 14:23:53 -05:00
db . prepare ( "UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?" ) . run (
trimmed ,
targetId ,
) ;
2026-05-14 01:17:05 -05:00
logAudit ( {
2026-07-06 14:23:53 -05:00
user_id : req.user.id ,
action : 'admin.username.change' ,
entity_type : 'user' ,
entity_id : targetId ,
2026-05-14 01:17:05 -05:00
details : { old_username : previousUsername , new_username : trimmed } ,
2026-07-06 14:23:53 -05:00
ip_address : req.ip ,
user_agent : req.get ( 'user-agent' ) ,
2026-05-14 01:17:05 -05:00
} ) ;
res . json (
2026-07-06 14:23:53 -05:00
db
. prepare (
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ,
)
. get ( targetId ) ,
2026-05-14 01:17:05 -05:00
) ;
} ) ;
2026-05-03 19:51:57 -05:00
// DELETE /api/admin/users/:id
2026-07-06 11:26:50 -05:00
router . delete ( '/users/:id' , ( req : Req , res : Res ) = > {
2026-06-03 20:28:37 -05:00
const targetId = parseUserId ( req . params ) ;
if ( ! targetId ) return res . status ( 400 ) . json ( { error : 'Invalid user ID' } ) ;
2026-07-06 14:23:53 -05:00
const db = getDb ( ) ;
2026-06-03 20:28:37 -05:00
const user = db . prepare ( 'SELECT * FROM users WHERE id = ?' ) . get ( targetId ) ;
2026-07-06 14:23:53 -05:00
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
if ( req . user ? . id === user . id )
return res . status ( 400 ) . json ( { error : 'You cannot delete your own account.' } ) ;
2026-05-04 23:34:24 -05:00
const deleteUser = db . transaction ( ( ) = > {
2026-05-31 15:06:10 -05:00
// These three tables have no FK/CASCADE to users — must delete explicitly.
// Sessions also has CASCADE but we keep the explicit delete as a safety net
// for the rare case where foreign_keys is temporarily OFF during a migration.
2026-05-04 23:34:24 -05:00
db . prepare ( 'DELETE FROM import_sessions WHERE user_id = ?' ) . run ( user . id ) ;
db . prepare ( 'DELETE FROM import_history WHERE user_id = ?' ) . run ( user . id ) ;
2026-05-31 15:06:10 -05:00
db . prepare ( 'DELETE FROM audit_log WHERE user_id = ?' ) . run ( user . id ) ;
2026-05-04 23:34:24 -05:00
db . prepare ( 'DELETE FROM sessions WHERE user_id = ?' ) . run ( user . id ) ;
db . prepare ( 'DELETE FROM users WHERE id = ?' ) . run ( user . id ) ;
2026-05-31 15:06:10 -05:00
// ON DELETE CASCADE handles: bills, payments, categories, monthly_bill_state,
// bill_history_ranges, notifications, data_sources, financial_accounts,
// transactions, user_settings, user_login_history, monthly_income,
// monthly_starting_amounts, autopay_suggestion_dismissals, bill_templates,
// match_suggestion_rejections, declined_subscription_hints, bill_merchant_rules,
// snowball_plans.
2026-05-04 23:34:24 -05:00
} ) ;
deleteUser ( ) ;
res . json ( { success : true , deleted_user_id : user.id } ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// ── Cleanup endpoints ─────────────────────────────────────────────────────────
// GET /api/admin/cleanup
// Returns current cleanup settings and the result of the last cleanup run.
2026-07-06 11:26:50 -05:00
router . get ( '/cleanup' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( getCleanupStatus ( ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// PUT /api/admin/cleanup
// Updates one or more cleanup settings. Accepts partial objects.
// import_sessions_enabled boolean prune expired import preview sessions
// temp_exports_enabled boolean prune stale SQLite export temp files
// temp_export_max_age_hours 1– 72 hours before an orphaned export file is removed
// 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 30– 3650 age threshold for import history rows
2026-07-06 11:26:50 -05:00
router . put ( '/cleanup' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
res . json ( applyCleanupSettings ( req . body ) ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// POST /api/admin/cleanup/run
// Runs all enabled cleanup tasks immediately and returns the result.
2026-07-06 11:26:50 -05:00
router . post ( '/cleanup/run' , backupOperationLimiter , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
try {
const result = await runAllCleanup ( ) ;
res . json ( result ) ;
} catch ( err ) {
sendError ( res , err ) ;
}
} ) ;
// ── Auth-mode helpers ─────────────────────────────────────────────────────────
const {
2026-05-16 15:38:28 -05:00
applyAuthModeSettings ,
buildAuthModeStatus ,
buildSubmittedOidcConfig ,
2026-05-03 19:51:57 -05:00
testOidcConfiguration ,
2026-07-06 11:22:44 -05:00
} = require ( '../services/oidcService.cts' ) ;
2026-05-03 19:51:57 -05:00
// GET /api/admin/auth-mode
2026-07-06 11:26:50 -05:00
router . get ( '/auth-mode' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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.
2026-07-06 11:26:50 -05:00
router . post ( '/auth-mode/oidc-test' , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const config = buildSubmittedOidcConfig ( req . body || { } ) ;
const result = await testOidcConfiguration ( config ) ;
res . status ( result . ok ? 200 : 400 ) . json ( result ) ;
} ) ;
// PUT /api/admin/auth-mode
// Accepts legacy auth_mode/default_user_id fields plus new auth method settings.
// Validates lockout protection before saving.
2026-07-06 11:26:50 -05:00
router . put ( '/auth-mode' , ( req : Req , res : Res ) = > {
2026-05-16 15:38:28 -05:00
try {
res . json ( applyAuthModeSettings ( req . body || { } ) ) ;
} catch ( err ) {
2026-07-06 14:23:53 -05:00
res
. status ( err . status || 500 )
. json ( { error : err.status ? err . message : 'Failed to update authentication settings' } ) ;
2026-05-03 19:51:57 -05:00
}
} ) ;
2026-05-28 22:06:15 -05:00
// ── Bank Sync Config ──────────────────────────────────────────────────────────
// GET /api/admin/bank-sync-config
2026-07-06 11:26:50 -05:00
router . get ( '/bank-sync-config' , ( req : Req , res : Res ) = > {
2026-07-06 14:23:53 -05:00
res . json ( {
. . . getBankSyncConfig ( ) ,
worker : getBankSyncWorkerStatus ( ) ,
encryption_key_source : isEnvKeyActive ( ) ? 'env' : 'db' ,
key_fingerprint : keyFingerprint ( ) ,
} ) ;
2026-05-28 22:06:15 -05:00
} ) ;
// PUT /api/admin/bank-sync-config
2026-07-06 11:26:50 -05:00
router . put ( '/bank-sync-config' , ( req : Req , res : Res ) = > {
2026-06-07 19:41:17 -05:00
const { enabled , sync_interval_hours , sync_days , debug_logging } = req . body || { } ;
2026-05-28 22:06:15 -05:00
try {
feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink
#1 Sync interval in admin UI:
- bankSyncConfigService: reads simplefin_sync_interval_hours from settings
(DB-first, env fallback, default 4h), setSyncIntervalHours() with validation
- bankSyncWorker: live-updates interval from getBankSyncConfig() each tick
- routes/admin: PUT accepts enabled and sync_interval_hours independently
- BankSyncAdminCard: number input (0.5 step, 0.5-168 range), dirty-checks both
#3 Auto-match after background sync:
- matchSuggestionService: autoMatchForUser() auto-applies suggestions ≥80
score (exact amount + date ±1d + name signal), lazy-requires matchTransactionToBill
- bankSyncWorker: calls autoMatchForUser after each successful sync, own try/catch
#4 Encryption note in BankSyncAdminCard below worker status panel
Also: error handling, admin link in tracker sidebar, SimpleFIN bridge hyperlink
2026-05-29 00:28:50 -05:00
let config = getBankSyncConfig ( ) ;
2026-05-29 01:33:54 -05:00
if ( typeof enabled === 'boolean' ) config = setBankSyncEnabled ( enabled ) ;
if ( sync_interval_hours !== undefined ) config = setSyncIntervalHours ( sync_interval_hours ) ;
if ( sync_days !== undefined ) config = setSyncDays ( sync_days ) ;
2026-06-07 19:41:17 -05:00
if ( typeof debug_logging === 'boolean' ) config = setDebugLogging ( debug_logging ) ;
feat: configurable sync interval, auto-match, encryption note, admin link, SimpleFIN hyperlink
#1 Sync interval in admin UI:
- bankSyncConfigService: reads simplefin_sync_interval_hours from settings
(DB-first, env fallback, default 4h), setSyncIntervalHours() with validation
- bankSyncWorker: live-updates interval from getBankSyncConfig() each tick
- routes/admin: PUT accepts enabled and sync_interval_hours independently
- BankSyncAdminCard: number input (0.5 step, 0.5-168 range), dirty-checks both
#3 Auto-match after background sync:
- matchSuggestionService: autoMatchForUser() auto-applies suggestions ≥80
score (exact amount + date ±1d + name signal), lazy-requires matchTransactionToBill
- bankSyncWorker: calls autoMatchForUser after each successful sync, own try/catch
#4 Encryption note in BankSyncAdminCard below worker status panel
Also: error handling, admin link in tracker sidebar, SimpleFIN bridge hyperlink
2026-05-29 00:28:50 -05:00
res . json ( config ) ;
2026-05-28 22:06:15 -05:00
} catch ( err ) {
2026-07-10 16:36:27 -05:00
// 4xx setter validation messages are user-facing; 5xx internals are not.
if ( err . status && err . status < 500 ) {
return res . status ( err . status ) . json ( { error : err.message } ) ;
}
log . error ( '[admin/bank-sync-config]' , err ) ;
res . status ( 500 ) . json ( { error : 'Failed to update bank sync config' } ) ;
2026-05-28 22:06:15 -05:00
}
} ) ;
2026-05-10 10:44:39 -05:00
// ── Migration Rollback ────────────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/migrations/rollback' , async ( req : Req , res : Res ) = > {
2026-05-10 10:44:39 -05:00
const { version } = req . body ;
if ( ! version ) {
return res . status ( 400 ) . json ( { error : 'Version is required' } ) ;
}
try {
const result = rollbackMigration ( version ) ;
logAudit ( {
user_id : req.user.id ,
action : 'migration.rollback' ,
entity_type : 'migration' ,
entity_id : null ,
details : { version , performed_by : req.user.username } ,
ip_address : req.ip ,
2026-07-06 14:23:53 -05:00
user_agent : req.get ( 'user-agent' ) ,
2026-05-10 10:44:39 -05:00
} ) ;
res . json ( { success : true , . . . result } ) ;
} catch ( err ) {
logAudit ( {
user_id : req.user.id ,
action : 'migration.rollback.failure' ,
entity_type : 'migration' ,
entity_id : null ,
details : { version , error : err.message , performed_by : req.user.username } ,
ip_address : req.ip ,
2026-07-06 14:23:53 -05:00
user_agent : req.get ( 'user-agent' ) ,
2026-05-10 10:44:39 -05:00
} ) ;
if ( err . code === 'NOT_APPLIED' ) {
return res . status ( 404 ) . json ( { error : err.message } ) ;
}
if ( err . code === 'ROLLBACK_NOT_SUPPORTED' ) {
return res . status ( 422 ) . json ( { error : err.message } ) ;
}
2026-07-10 16:36:27 -05:00
// Unknown failure: the audit log (above) keeps err.message; the response must not.
log . error ( '[admin/migrations/rollback]' , err ) ;
res . status ( 500 ) . json ( { error : 'Rollback failed' } ) ;
2026-05-10 10:44:39 -05:00
}
} ) ;
2026-05-03 19:51:57 -05:00
module .exports = router ;