refactor(server): migrate 8 leaf services + apiError to TypeScript (.cts)
Batch of low-blast-radius modules: utils/apiError, and services statusRuntime, loginFingerprint, auditService, transactionMatchState, updateCheckService, advisoryFilterService, bankSyncConfigService. - types/http.d.ts: shared permissive Express-ish Req/Res/Next/Router types (@types/express isn't installed; handlers are dynamic) — for apiError's errorHandler now and the routes/middleware batches next. - Every require of a migrated module updated to the explicit .cts extension. - Confirmed + documented the Node rule: a .cts needs at least one `import` statement or type-stripping never activates (node --check fails on the first type token); import-less modules lead with an `import type`. Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
005b0b7b64
commit
a6c9b6cb1c
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const crypto = require('crypto');
|
||||
const { logAudit } = require('../services/auditService');
|
||||
const { logAudit } = require('../services/auditService.cts');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CSRF Middleware
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,7 +30,7 @@ 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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<Object>} 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<any> {
|
||||
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,
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -10,8 +10,15 @@
|
|||
* }
|
||||
*/
|
||||
|
||||
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;
|
||||
|
|
@ -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,9 +78,9 @@ 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,
|
||||
};
|
||||
|
|
@ -95,7 +102,7 @@ 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);
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue