diff --git a/db/database.js b/db/database.js index e339bdc..bb2cc4f 100644 --- a/db/database.js +++ b/db/database.js @@ -7,7 +7,7 @@ const fs = require('fs'); let _logAudit = null; function getLogAudit() { if (!_logAudit) { - try { _logAudit = require('../services/auditService').logAudit; } catch { _logAudit = () => {}; } + try { _logAudit = require('../services/auditService.cts').logAudit; } catch { _logAudit = () => {}; } } return _logAudit; } diff --git a/middleware/csrf.js b/middleware/csrf.js index 7d3bb36..3694bb0 100644 --- a/middleware/csrf.js +++ b/middleware/csrf.js @@ -1,5 +1,5 @@ const crypto = require('crypto'); -const { logAudit } = require('../services/auditService'); +const { logAudit } = require('../services/auditService.cts'); // ───────────────────────────────────────────────────────────────────────────── // CSRF Middleware diff --git a/middleware/errorFormatter.js b/middleware/errorFormatter.js index 4791f97..9d5328d 100644 --- a/middleware/errorFormatter.js +++ b/middleware/errorFormatter.js @@ -10,7 +10,7 @@ * } */ -const { ValidationError, formatError } = require('../utils/apiError'); +const { ValidationError, formatError } = require('../utils/apiError.cts'); /** * Extract field name from various validation error patterns diff --git a/routes/aboutAdmin.js b/routes/aboutAdmin.js index 7d3b942..4ee78b0 100644 --- a/routes/aboutAdmin.js +++ b/routes/aboutAdmin.js @@ -475,7 +475,7 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => { } }); -const { checkForUpdates } = require('../services/updateCheckService'); +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) => { diff --git a/routes/admin.js b/routes/admin.js index 2085b38..3bc56a4 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,11 +1,11 @@ const express = require('express'); const router = express.Router(); const { getDb, rollbackMigration } = require('../db/database'); -const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService'); +const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService.cts'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService.cts'); const { hashPassword } = require('../services/authService'); -const { logAudit } = require('../services/auditService'); +const { logAudit } = require('../services/auditService.cts'); const { createBackup, deleteBackup, diff --git a/routes/auth.js b/routes/auth.js index 4ef3e6d..b5b8894 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -15,10 +15,10 @@ const { decryptSecret } = require('../services/encryptionService.cts'); const { getCsrfToken } = require('../middleware/csrf'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { getPublicOidcInfo } = require('../services/oidcService'); -const { ValidationError, formatError } = require('../utils/apiError'); +const { ValidationError, formatError } = require('../utils/apiError.cts'); const { standardizeError } = require('../middleware/errorFormatter'); const { passwordLimiter } = require('../middleware/rateLimiter'); -const { logAudit } = require('../services/auditService'); +const { logAudit } = require('../services/auditService.cts'); // ───────────────────────────────────────── // PUBLIC AUTH ROUTES diff --git a/routes/dataSources.js b/routes/dataSources.js index 9cdb02c..a51e925 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -6,7 +6,7 @@ const { standardizeError } = require('../middleware/erro const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService'); const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService'); const { sanitizeErrorMessage } = require('../services/simplefinService'); -const { getBankSyncConfig } = require('../services/bankSyncConfigService'); +const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); const { syncLimiter } = require('../middleware/rateLimiter'); const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']); diff --git a/routes/matches.js b/routes/matches.js index cf52711..87ac30b 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -7,7 +7,7 @@ const { rejectMatchSuggestion, } = require('../services/matchSuggestionService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts'); -const { markMatched, markUnmatched } = require('../services/transactionMatchState'); +const { markMatched, markUnmatched } = require('../services/transactionMatchState.cts'); const { serializePayment } = require('../services/paymentValidation.cts'); const { todayLocal } = require('../utils/dates.mts'); diff --git a/routes/payments.js b/routes/payments.js index 940787b..9379236 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -5,7 +5,7 @@ const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); const { getCycleRange, resolveDueDate } = require('../services/statusService.cts'); -const { markUnmatched } = require('../services/transactionMatchState'); +const { markUnmatched } = require('../services/transactionMatchState.cts'); const { markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, diff --git a/routes/profile.js b/routes/profile.js index cbbd9ef..462c128 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -8,7 +8,7 @@ const { passwordLimiter } = require('../middleware/rateLimiter'); const { getDb, getSetting } = require('../db/database'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService'); const { getImportHistory } = require('../services/spreadsheetImportService'); -const { logAudit } = require('../services/auditService'); +const { logAudit } = require('../services/auditService.cts'); const { standardizeError } = require('../middleware/errorFormatter'); const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts'); diff --git a/routes/status.js b/routes/status.js index c9763a9..3ba3933 100644 --- a/routes/status.js +++ b/routes/status.js @@ -3,12 +3,12 @@ const router = express.Router(); const fs = require('fs'); const os = require('os'); const { getDb, getSetting } = require('../db/database'); -const { getStatusRuntime, recordError } = require('../services/statusRuntime'); +const { getStatusRuntime, recordError } = require('../services/statusRuntime.cts'); const { listBackups } = require('../services/backupService'); const { getScheduleStatus } = require('../services/backupScheduler'); -const { checkForUpdates } = require('../services/updateCheckService'); +const { checkForUpdates } = require('../services/updateCheckService.cts'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); -const { getBankSyncConfig } = require('../services/bankSyncConfigService'); +const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { localDateString } = require('../utils/dates.mts'); diff --git a/routes/transactions.js b/routes/transactions.js index 0b061f4..16f9542 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -6,9 +6,9 @@ const { ensureManualDataSource, getTransactionForUser, } = require('../services/transactionService'); -const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService'); -const { getBankSyncConfig } = require('../services/bankSyncConfigService'); -const { markUnmatched } = require('../services/transactionMatchState'); +const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService.cts'); +const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); +const { markUnmatched } = require('../services/transactionMatchState.cts'); const { ignoreTransaction, matchTransactionToBill, diff --git a/routes/version.js b/routes/version.js index 34237ab..53bb1c1 100644 --- a/routes/version.js +++ b/routes/version.js @@ -78,7 +78,7 @@ router.get('/history', (req, res) => { }); // GET /api/version/update-status — public, returns cached update check (no force-refresh) -const { checkForUpdates } = require('../services/updateCheckService'); +const { checkForUpdates } = require('../services/updateCheckService.cts'); router.get('/update-status', async (req, res) => { try { diff --git a/server.js b/server.js index b838b7f..116930c 100644 --- a/server.js +++ b/server.js @@ -4,9 +4,9 @@ const path = require('path'); const { getDb } = require('./db/database'); const { requireAuth, requireUser, requireAdmin } = require('./middleware/requireAuth'); -const { recordError } = require('./services/statusRuntime'); +const { recordError } = require('./services/statusRuntime.cts'); const { securityHeaders } = require('./middleware/securityHeaders'); -const { logAudit } = require('./services/auditService'); +const { logAudit } = require('./services/auditService.cts'); const { errorFormatter } = require('./middleware/errorFormatter'); const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, loginUsernameLimiter, passwordLimiter, backupOperationLimiter } = require('./middleware/rateLimiter'); diff --git a/services/advisoryFilterService.js b/services/advisoryFilterService.cts similarity index 75% rename from services/advisoryFilterService.js rename to services/advisoryFilterService.cts index 304dace..11a224f 100644 --- a/services/advisoryFilterService.js +++ b/services/advisoryFilterService.cts @@ -1,12 +1,14 @@ 'use strict'; +import type { Db } from '../types/db'; + const { getDb } = require('../db/database'); // Lazy-loaded in-memory cache — loaded once on first use -let _patterns = null; -let _overrideTerms = null; +let _patterns: any[] | null = null; +let _overrideTerms: string[] | null = null; -function normalize(text) { +function normalize(text: unknown): string { if (!text) return ''; return String(text) .toLowerCase() @@ -15,15 +17,15 @@ function normalize(text) { .trim(); } -function loadCache() { +function loadCache(): void { if (_patterns !== null) return; - const db = getDb(); + const db: Db = getDb(); _patterns = db.prepare( 'SELECT pattern, confidence, category, rationale FROM advisory_non_bill_filters' ).all(); _overrideTerms = db.prepare( 'SELECT term FROM advisory_bill_like_overrides' - ).all().map(r => r.term); + ).all().map((r: any) => r.term); } /** @@ -31,7 +33,7 @@ function loadCache() { * Returns null if Create Bill should be shown normally, or * { confidence: 'high'|'medium', category, rationale } if it should be suppressed. */ -function checkTransaction(title) { +function checkTransaction(title: unknown): { confidence: string; category: any; rationale: any } | null { if (!title) return null; try { loadCache(); @@ -43,15 +45,15 @@ function checkTransaction(title) { if (!normalized) return null; // Bill-like override terms take priority — always show Create Bill - for (const term of _overrideTerms) { + for (const term of _overrideTerms!) { if (normalized.includes(term)) return null; } // Find the highest-confidence matching pattern - let highMatch = null; - let mediumMatch = null; + let highMatch: any = null; + let mediumMatch: any = null; - for (const row of _patterns) { + for (const row of _patterns!) { if (normalized.includes(row.pattern)) { if (row.confidence === 'high') { highMatch = row; @@ -73,7 +75,7 @@ function checkTransaction(title) { } /** Clear the in-memory cache (used after re-seeding in tests). */ -function clearCache() { +function clearCache(): void { _patterns = null; _overrideTerms = null; } diff --git a/services/auditService.js b/services/auditService.cts similarity index 50% rename from services/auditService.js rename to services/auditService.cts index 6fcd735..316d16b 100644 --- a/services/auditService.js +++ b/services/auditService.cts @@ -1,18 +1,22 @@ +import type { Db } from '../types/db'; + const { getDb } = require('../db/database'); +interface AuditParams { + user_id?: number | null; + action: string; + entity_type?: string | null; + entity_id?: number | null; + details?: any; + ip_address?: string | null; + user_agent?: string | null; +} + /** * Log a security-sensitive action to the audit_log table. - * @param {Object} params - * @param {number|null} params.user_id - User ID (null for anonymous/failed attempts) - * @param {string} params.action - Action type (e.g., 'login.success', 'login.failure', 'password.change', 'role.change', 'session.invalidate') - * @param {string} [params.entity_type] - Entity type (e.g., 'user', 'session', 'bill') - * @param {number} [params.entity_id] - Entity ID - * @param {Object} [params.details] - Additional details (stored as JSON) - * @param {string} [params.ip_address] - Request IP - * @param {string} [params.user_agent] - Request user-agent */ -function logAudit({ user_id, action, entity_type, entity_id, details, ip_address, user_agent }) { - const db = getDb(); +function logAudit({ user_id, action, entity_type, entity_id, details, ip_address, user_agent }: AuditParams): void { + const db: Db = getDb(); try { db.prepare( `INSERT INTO audit_log (user_id, action, entity_type, entity_id, details_json, ip_address, user_agent) @@ -26,10 +30,10 @@ function logAudit({ user_id, action, entity_type, entity_id, details, ip_address ip_address || null, user_agent || null ); - } catch (err) { + } catch (err: any) { // Audit logging should never crash the app console.error('[audit-error] Failed to log audit event:', err.message); } } -module.exports = { logAudit }; \ No newline at end of file +module.exports = { logAudit }; diff --git a/services/authService.js b/services/authService.js index 683f781..bf7653b 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,7 +1,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { getDb } = require('../db/database'); -const { buildDeviceFingerprint } = require('./loginFingerprint'); +const { buildDeviceFingerprint } = require('./loginFingerprint.cts'); const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); // Store SHA-256(token) in the DB; the raw token stays only in the cookie. diff --git a/services/bankSyncConfigService.js b/services/bankSyncConfigService.cts similarity index 80% rename from services/bankSyncConfigService.js rename to services/bankSyncConfigService.cts index c49b6a3..e82470b 100644 --- a/services/bankSyncConfigService.js +++ b/services/bankSyncConfigService.cts @@ -1,5 +1,8 @@ 'use strict'; +// import required so Node type-strips this .cts (it has no other import). +import type { Db } from '../types/db'; + const { getSetting, setSetting } = require('../db/database'); const SYNC_DAYS_MAX = 45; // SimpleFIN Bridge hard limit — requests beyond this return an error @@ -7,11 +10,20 @@ const SYNC_DAYS_EFFECTIVE = 44; // 1-day buffer so request latency never tips const SYNC_DAYS_DEFAULT = 30; // routine sync window (initial seed always uses SYNC_DAYS_EFFECTIVE) const SYNC_INTERVAL_DEFAULT = 4; // hours -function getBankSyncConfig() { +interface BankSyncConfig { + enabled: boolean; + sync_days: number; + seed_days: number; + sync_days_max: number; + sync_interval_hours: number; + debug_logging: boolean; +} + +function getBankSyncConfig(): BankSyncConfig { const dbValue = getSetting('bank_sync_enabled'); const envValue = process.env.BANK_SYNC_ENABLED; - let enabled; + let enabled: boolean; if (dbValue !== null && dbValue !== undefined && dbValue !== '') { enabled = dbValue === 'true'; } else if (envValue !== undefined && envValue !== '') { @@ -51,13 +63,13 @@ function getBankSyncConfig() { }; } -function setBankSyncEnabled(enabled) { +function setBankSyncEnabled(enabled: boolean): BankSyncConfig { setSetting('bank_sync_enabled', enabled ? 'true' : 'false'); return getBankSyncConfig(); } -function setSyncIntervalHours(hours) { - const n = parseFloat(hours); +function setSyncIntervalHours(hours: number | string): BankSyncConfig { + const n = parseFloat(hours as string); if (!Number.isFinite(n) || n < 0.5 || n > 168) { throw Object.assign(new Error('sync_interval_hours must be between 0.5 and 168'), { status: 400 }); } @@ -65,8 +77,8 @@ function setSyncIntervalHours(hours) { return getBankSyncConfig(); } -function setSyncDays(days) { - const n = parseInt(days, 10); +function setSyncDays(days: number | string): BankSyncConfig { + const n = parseInt(days as string, 10); if (!Number.isFinite(n) || n < 1 || n > SYNC_DAYS_MAX) { throw Object.assign(new Error(`sync_days must be between 1 and ${SYNC_DAYS_MAX} (SimpleFIN Bridge hard limit)`), { status: 400 }); } @@ -74,7 +86,7 @@ function setSyncDays(days) { return getBankSyncConfig(); } -function setDebugLogging(enabled) { +function setDebugLogging(enabled: boolean): BankSyncConfig { setSetting('simplefin_debug_logging', enabled ? 'true' : 'false'); return getBankSyncConfig(); } diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 2ff885a..93aaef8 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -8,7 +8,7 @@ const { normalizeTransaction, sanitizeErrorMessage, } = require('./simplefinService'); -const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService'); +const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService.cts'); const { decorateDataSource } = require('./transactionService'); const { applyMerchantRules } = require('./billMerchantRuleService.cts'); const { applySpendingCategoryRules } = require('./spendingService.cts'); diff --git a/services/bankSyncWorker.js b/services/bankSyncWorker.js index eedab97..ec3e88b 100644 --- a/services/bankSyncWorker.js +++ b/services/bankSyncWorker.js @@ -1,7 +1,7 @@ 'use strict'; const { getDb } = require('../db/database'); -const { getBankSyncConfig } = require('./bankSyncConfigService'); +const { getBankSyncConfig } = require('./bankSyncConfigService.cts'); const { syncDataSource } = require('./bankSyncService'); // Skip a source if it was synced less than this long ago (catches recent manual syncs) diff --git a/services/loginFingerprint.js b/services/loginFingerprint.cts similarity index 81% rename from services/loginFingerprint.js rename to services/loginFingerprint.cts index 00097be..9027a56 100644 --- a/services/loginFingerprint.js +++ b/services/loginFingerprint.cts @@ -1,8 +1,11 @@ 'use strict'; +// import required so Node type-strips this .cts (it has no other import). +import type { Db } from '../types/db'; + const crypto = require('crypto'); -function parseBrowser(userAgent) { +function parseBrowser(userAgent: unknown): string { const ua = String(userAgent || ''); if (/Edg\//i.test(ua)) return 'Edge'; if (/OPR\//i.test(ua) || /Opera/i.test(ua)) return 'Opera'; @@ -14,7 +17,7 @@ function parseBrowser(userAgent) { return 'Unknown'; } -function parseOs(userAgent) { +function parseOs(userAgent: unknown): string { const ua = String(userAgent || ''); if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS'; if (/Android/i.test(ua)) return 'Android'; @@ -25,7 +28,7 @@ function parseOs(userAgent) { return 'Unknown'; } -function parseDeviceType(userAgent) { +function parseDeviceType(userAgent: unknown): string { const ua = String(userAgent || ''); if (/iPad|Tablet/i.test(ua)) return 'tablet'; if (/Mobi|iPhone|Android/i.test(ua)) return 'mobile'; @@ -33,7 +36,7 @@ function parseDeviceType(userAgent) { return 'desktop'; } -function coarseIpPrefix(ipAddress) { +function coarseIpPrefix(ipAddress: unknown): string { const ip = String(ipAddress || '').trim(); if (!ip) return ''; @@ -47,7 +50,7 @@ function coarseIpPrefix(ipAddress) { return ip; } -function buildDeviceFingerprint({ userAgent, ipAddress }) { +function buildDeviceFingerprint({ userAgent, ipAddress }: { userAgent?: unknown; ipAddress?: unknown }) { const browser = parseBrowser(userAgent); const os = parseOs(userAgent); const deviceType = parseDeviceType(userAgent); diff --git a/services/notificationService.js b/services/notificationService.js index e318063..f61942f 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -6,7 +6,7 @@ const { markNotificationError, markNotificationSuccess, markNotificationTestSuccess, -} = require('./statusRuntime'); +} = require('./statusRuntime.cts'); const { localDateString } = require('../utils/dates.mts'); const { fromCents } = require('../utils/money.mts'); diff --git a/services/statusRuntime.js b/services/statusRuntime.cts similarity index 74% rename from services/statusRuntime.js rename to services/statusRuntime.cts index 5f2d9bc..2fe521c 100644 --- a/services/statusRuntime.js +++ b/services/statusRuntime.cts @@ -1,5 +1,8 @@ 'use strict'; +// import required so Node type-strips this .cts (it has no other import). +import type { Db } from '../types/db'; + const MAX_RECENT_ERRORS = 10; // Keys written to the settings table for cross-restart persistence @@ -10,7 +13,18 @@ const DB_KEY = { workerStarted: '_worker_started_at', }; -const state = { +interface WorkerState { + enabled: boolean; + running: boolean; + started_at: string | null; + last_run_at: string | null; + next_run_at: string | null; + last_error: string | null; +} +interface NotificationsState { last_test_at: string | null; last_error: string | null } +interface RuntimeError { timestamp: string; source: string; message: string } + +const state: { worker: WorkerState; notifications: NotificationsState; recentErrors: RuntimeError[] } = { worker: { enabled: false, running: false, @@ -28,7 +42,7 @@ const state = { // Seed from DB on first load so status survives container restarts. // Wrapped in try/catch — DB may not be ready at require-time in tests. -function seedFromDb() { +function seedFromDb(): void { try { const { getSetting } = require('../db/database'); state.worker.last_run_at = getSetting(DB_KEY.workerLastRun) || null; @@ -40,27 +54,27 @@ function seedFromDb() { } seedFromDb(); -function dbSet(key, value) { +function dbSet(key: string, value: unknown): void { try { const { setSetting } = require('../db/database'); setSetting(key, value == null ? '' : String(value)); } catch { /* non-fatal */ } } -function toMessage(error) { +function toMessage(error: unknown): string | null { if (!error) return null; if (typeof error === 'string') return error; - return error.message || String(error); + return (error as any).message || String(error); } -function recordError(source, error) { +function recordError(source: string, error: unknown): void { const message = toMessage(error); if (!message) return; state.recentErrors.unshift({ timestamp: new Date().toISOString(), source, message }); state.recentErrors = state.recentErrors.slice(0, MAX_RECENT_ERRORS); } -function markWorkerStarted(nextRunAt = null) { +function markWorkerStarted(nextRunAt: string | null = null): void { const now = new Date().toISOString(); state.worker.enabled = true; state.worker.running = true; @@ -70,7 +84,7 @@ function markWorkerStarted(nextRunAt = null) { if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt); } -function markWorkerSuccess(nextRunAt = null) { +function markWorkerSuccess(nextRunAt: string | null = null): void { const now = new Date().toISOString(); state.worker.running = false; state.worker.last_run_at = now; @@ -81,7 +95,7 @@ function markWorkerSuccess(nextRunAt = null) { if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt); } -function markWorkerError(error, nextRunAt = null) { +function markWorkerError(error: unknown, nextRunAt: string | null = null): void { const now = new Date().toISOString(); state.worker.running = false; state.worker.last_run_at = now; @@ -93,17 +107,17 @@ function markWorkerError(error, nextRunAt = null) { recordError('Daily Worker', error); } -function markNotificationTestSuccess() { +function markNotificationTestSuccess(): void { state.notifications.last_test_at = new Date().toISOString(); state.notifications.last_error = null; } -function markNotificationError(error) { +function markNotificationError(error: unknown): void { state.notifications.last_error = toMessage(error); recordError('Notifications', error); } -function markNotificationSuccess() { +function markNotificationSuccess(): void { state.notifications.last_error = null; } diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index b9b5eb4..25496ba 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -11,7 +11,7 @@ const { getTransactionForUser, } = require('./transactionService'); const { serializePayment } = require('./paymentValidation.cts'); -const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState'); +const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState.cts'); const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match'; diff --git a/services/transactionMatchState.js b/services/transactionMatchState.cts similarity index 75% rename from services/transactionMatchState.js rename to services/transactionMatchState.cts index 9745fcc..e3180d1 100644 --- a/services/transactionMatchState.js +++ b/services/transactionMatchState.cts @@ -1,5 +1,7 @@ 'use strict'; +import type { Db } from '../types/db'; + // Canonical writers for a transaction's match state. // // A transaction's match state lives in three columns that must move together: @@ -17,10 +19,10 @@ /** * Mark a transaction matched to a bill. - * @param {boolean} [opts.resetIgnored] also clear the ignored flag (used when - * matching a transaction directly, which implicitly un-ignores it). + * @param resetIgnored also clear the ignored flag (used when matching a + * transaction directly, which implicitly un-ignores it). */ -function markMatched(db, userId, transactionId, billId, { resetIgnored = false } = {}) { +function markMatched(db: Db, userId: number, transactionId: number, billId: number, { resetIgnored = false }: { resetIgnored?: boolean } = {}): number { return db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now') @@ -30,9 +32,9 @@ function markMatched(db, userId, transactionId, billId, { resetIgnored = false } /** * Clear a transaction's match — back to 'unmatched' with no bill. - * @param {boolean} [opts.resetIgnored] also clear the ignored flag (un-ignore). + * @param resetIgnored also clear the ignored flag (un-ignore). */ -function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {}) { +function markUnmatched(db: Db, userId: number, transactionId: number, { resetIgnored = false }: { resetIgnored?: boolean } = {}): number { return db.prepare(` UPDATE transactions SET matched_bill_id = NULL, match_status = 'unmatched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now') @@ -41,7 +43,7 @@ function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {}) } /** Mark a transaction ignored — dropped from matching, no bill. */ -function markIgnored(db, userId, transactionId) { +function markIgnored(db: Db, userId: number, transactionId: number): number { return db.prepare(` UPDATE transactions SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now') diff --git a/services/updateCheckService.js b/services/updateCheckService.cts similarity index 86% rename from services/updateCheckService.js rename to services/updateCheckService.cts index f0ad31a..92512a4 100644 --- a/services/updateCheckService.js +++ b/services/updateCheckService.cts @@ -4,6 +4,9 @@ * 5 minutes for errors) so the status page stays fast under load. */ +// import required so Node type-strips this .cts (it has no other import). +import type { Db } from '../types/db'; + const { getSetting } = require('../db/database'); const REPO_API_BASE = process.env.REPO_API_URL @@ -12,7 +15,7 @@ const REPO_API_BASE = process.env.REPO_API_URL // QA-B16-01: the version check is opt-out-able. Admins can disable the external // request via the `update_check_enabled` setting (default on). When off, no // network call is made and a `disabled` status is returned. -function updateCheckEnabled() { +function updateCheckEnabled(): boolean { return getSetting('update_check_enabled') !== 'false'; } @@ -20,10 +23,10 @@ const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering) const FETCH_TIMEOUT_MS = 8_000; -let _cache = { result: null, expiresAt: 0 }; -let _pkg = null; +let _cache: { result: any; expiresAt: number } = { result: null, expiresAt: 0 }; +let _pkg: any = null; -function getCurrentVersion() { +function getCurrentVersion(): string { if (!_pkg) { try { _pkg = require('../package.json'); } catch { _pkg = { version: '0.0.0' }; } } @@ -31,8 +34,8 @@ function getCurrentVersion() { } // Returns positive if a > b, negative if a < b, 0 if equal. -function compareVersions(a, b) { - const parse = v => String(v).replace(/^v/, '').split('.').map(Number); +function compareVersions(a: string, b: string): number { + const parse = (v: string) => String(v).replace(/^v/, '').split('.').map(Number); const pa = parse(a), pb = parse(b); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const diff = (pa[i] || 0) - (pb[i] || 0); @@ -42,10 +45,10 @@ function compareVersions(a, b) { } /** - * @param {boolean} force Skip the cache and always hit the API. - * @returns {Promise} Update status object. + * @param force Skip the cache and always hit the API. + * @returns Update status object. */ -async function checkForUpdates(force = false) { +async function checkForUpdates(force = false): Promise { const now = Date.now(); // Opt-out: no external request when disabled by the admin. @@ -99,7 +102,7 @@ async function checkForUpdates(force = false) { if (!res.ok) throw new Error(`Forgejo API returned HTTP ${res.status}`); - const release = await res.json(); + const release: any = await res.json(); const rawTag = release.tag_name || ''; const latestVersion = rawTag.replace(/^v/, '') || null; const hasUpdate = latestVersion @@ -121,7 +124,7 @@ async function checkForUpdates(force = false) { _cache = { result, expiresAt: now + TTL_OK_MS }; return result; - } catch (err) { + } catch (err: any) { const result = { current_version: currentVersion, latest_version: null, diff --git a/tests/transactionMatchState.test.js b/tests/transactionMatchState.test.js index 8d59ad1..3a520ab 100644 --- a/tests/transactionMatchState.test.js +++ b/tests/transactionMatchState.test.js @@ -13,7 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-match-state-${process.pid}.s process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState'); +const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState.cts'); let db, userId, otherId, billId; diff --git a/tests/updateCheckOptOut.test.js b/tests/updateCheckOptOut.test.js index 576d10b..51d6a72 100644 --- a/tests/updateCheckOptOut.test.js +++ b/tests/updateCheckOptOut.test.js @@ -13,7 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-updatecheck-test-${process.p process.env.DB_PATH = dbPath; const { getDb, setSetting, closeDb } = require('../db/database'); -const svc = require('../services/updateCheckService'); +const svc = require('../services/updateCheckService.cts'); test('disabled: makes no external request and returns disabled', async () => { getDb(); diff --git a/types/http.d.ts b/types/http.d.ts new file mode 100644 index 0000000..e8d4f6c --- /dev/null +++ b/types/http.d.ts @@ -0,0 +1,60 @@ +// Minimal permissive Express-ish request/response types for the migrated routes +// and middleware. @types/express is not installed and the handlers are dynamic +// (req.user is added by requireAuth, req.body/query are user input), so these +// carry an index signature — typed enough for `.json()/.status()` chaining and +// the common fields, permissive everywhere else. Swap for @types/express + +// declaration-merged `user` if the routes ever warrant full typing. + +export interface Req { + body: any; + params: any; + query: any; + user?: any; + ip?: string; + method: string; + path?: string; + originalUrl?: string; + headers: any; + cookies?: any; + signedCookies?: any; + secure?: boolean; + csrfSkip?: boolean; + file?: any; + files?: any; + get(name: string): string | undefined; + header(name: string): string | undefined; + [k: string]: any; +} + +export interface Res { + json(body?: any): Res; + status(code: number): Res; + send(body?: any): Res; + end(body?: any): Res; + cookie(name: string, value: any, options?: any): Res; + clearCookie(name: string, options?: any): Res; + setHeader(name: string, value: any): void; + getHeader(name: string): any; + set(field: string, value?: any): Res; + type(t: string): Res; + redirect(url: string): void; + download(path: string, filename?: string, cb?: (err?: any) => void): void; + sendFile(path: string, options?: any, cb?: (err?: any) => void): void; + attachment(filename?: string): Res; + locals: any; + headersSent: boolean; + [k: string]: any; +} + +export type Next = (err?: any) => void; + +// An Express router (the shape `express.Router()` returns) — permissive. +export interface Router { + get(path: string, ...handlers: any[]): void; + post(path: string, ...handlers: any[]): void; + put(path: string, ...handlers: any[]): void; + patch(path: string, ...handlers: any[]): void; + delete(path: string, ...handlers: any[]): void; + use(...handlers: any[]): void; + [k: string]: any; +} diff --git a/utils/apiError.js b/utils/apiError.cts similarity index 78% rename from utils/apiError.js rename to utils/apiError.cts index d39a319..92a9c56 100644 --- a/utils/apiError.js +++ b/utils/apiError.cts @@ -1,6 +1,6 @@ /** * Centralized error handling utility for API routes - * + * * Standard error format: * { * error: 'ErrorType', @@ -10,14 +10,21 @@ * } */ +import type { Req, Res, Next } from '../types/http'; + class ApiError extends Error { - constructor(code, message, status = 500, details = {}) { + code: string; + status: number; + details: any; + field?: string; + + constructor(code: string, message: string, status = 500, details: any = {}) { super(message); this.name = 'ApiError'; this.code = code; this.status = status; this.details = details; - + // Extract field name from details if provided if (details.field) { this.field = details.field; @@ -28,42 +35,42 @@ class ApiError extends Error { /** * Create a standardized validation error */ -function ValidationError(message, field, code = 'VALIDATION_ERROR') { +function ValidationError(message: string, field?: string, code = 'VALIDATION_ERROR'): ApiError { return new ApiError(code, message, 400, { field }); } /** * Create a standardized authentication error */ -function AuthError(message = 'Authentication required', code = 'AUTH_ERROR') { +function AuthError(message = 'Authentication required', code = 'AUTH_ERROR'): ApiError { return new ApiError(code, message, 401); } /** * Create a standardized authorization error */ -function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN') { +function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN'): ApiError { return new ApiError(code, message, 403); } /** * Create a standardized not found error */ -function NotFoundError(message = 'Resource not found', code = 'NOT_FOUND') { +function NotFoundError(message = 'Resource not found', code = 'NOT_FOUND'): ApiError { return new ApiError(code, message, 404); } /** * Create a standardized conflict error */ -function ConflictError(message = 'Resource conflict', code = 'CONFLICT') { +function ConflictError(message = 'Resource conflict', code = 'CONFLICT'): ApiError { return new ApiError(code, message, 409); } /** * Create a standardized rate limit error */ -function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED') { +function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED'): ApiError { return new ApiError(code, message, 429); } @@ -71,21 +78,21 @@ function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED') { * Format an error for JSON response * This ensures consistent error format across all routes */ -function formatError(err) { +function formatError(err: any): { error: string; message: string; field?: string } { if (err instanceof ApiError) { - const output = { + const output: { error: string; message: string; field?: string } = { error: err.code, message: err.message, }; if (err.field) output.field = err.field; return output; } - + // Fallback for non-standard errors (log internally, show safe message) - const safeMessage = err.status === 500 + const safeMessage = err.status === 500 ? 'Internal server error' : err.message || 'An error occurred'; - + return { error: err.code || 'ERROR', message: safeMessage, @@ -95,15 +102,15 @@ function formatError(err) { /** * Express middleware to handle errors centrally */ -function errorHandler(err, req, res, next) { +function errorHandler(err: any, req: Req, res: Res, next: Next): void { const formatted = formatError(err); const status = err.status || (err instanceof ApiError ? 500 : 500); - + // Only log non-500 errors to avoid exposing sensitive info if (status !== 500) { console.error(`[error] ${formatted.error}: ${formatted.message}`); } - + if (!res.headersSent) { res.status(status).json(formatted); } diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index cff916c..c52c179 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -11,7 +11,7 @@ const { markWorkerError, markWorkerStarted, markWorkerSuccess, -} = require('../services/statusRuntime'); +} = require('../services/statusRuntime.cts'); const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts'); // The hour (0–23) the single daily job runs — configurable via the global