refactor(routes): throw-pattern for profile, transactions, admin, aboutAdmin
Standards-unification batch 4b (completes the route conversion program —
all 29 route files now emit errors through the canonical path or a
documented exception):
- profile.cts: full conversion; try/catch-500 wrappers removed
- transactions.cts: boundary adapter throwStandardized() rethrows the
internal {error} parse/normalize results as ApiError (internal result
convention unchanged); sendTransactionServiceError -> throwing
toTransactionServiceError (4xx keep message/code, 5xx masked)
- admin.cts: user-management + bank-sync-config + rollback converted;
coded rollback errors (NOT_APPLIED 404 / ROLLBACK_NOT_SUPPORTED 422)
preserved as intentional messages
- aboutAdmin.cts: loose 400 converted; static no-path-disclosure 500s kept
Documented exceptions (intentional, commented inline): admin sendError
(backup download can fail mid-stream after headers sent), import.cts
sendImportError pipeline (error-id envelope), transactions bulk-unmatch
500 (carries per-item results), payments DUPLICATE_SUSPECTED 409 (carries
existing payment), calendarFeed text/plain 404 (ICS clients), authOidc
static 501/502 (rewritten wholesale in the openid-client v6 batch).
Server suite 252/252.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d0ab375f9a
commit
c481a5bdbf
|
|
@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts');
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
||||
const { ValidationError } = require('../utils/apiError.cts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -594,7 +595,7 @@ router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: R
|
|||
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
||||
const { enabled } = req.body || {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
throw ValidationError('enabled must be a boolean', 'enabled');
|
||||
}
|
||||
setSetting('update_check_enabled', enabled ? 'true' : 'false');
|
||||
res.json({ enabled });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ const express = require('express');
|
|||
const { log } = require('../utils/logger.cts');
|
||||
const router = express.Router();
|
||||
const { getDb, rollbackMigration } = require('../db/database.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const {
|
||||
getBankSyncConfig,
|
||||
setBankSyncEnabled,
|
||||
|
|
@ -43,6 +49,9 @@ function parseUserId(params) {
|
|||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
// Backup-ops error path. Kept res-based (not throw-pattern) deliberately: the
|
||||
// download route can fail mid-stream after headers are sent, where a throw
|
||||
// can't produce a response. Masks 500s; 4xx service messages pass through.
|
||||
function sendError(res, err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
|
||||
|
|
@ -170,15 +179,14 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
|
|||
router.post('/users', async (req: Req, res: Res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || username.length < 3)
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
throw ValidationError('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' });
|
||||
throw ValidationError('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' });
|
||||
throw ConflictError('Username already taken', 'username');
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
const result = db
|
||||
.prepare(
|
||||
|
|
@ -203,26 +211,21 @@ router.post('/users', async (req: Req, res: Res) => {
|
|||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
log.error('[admin] create-user error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/password
|
||||
router.put('/users/:id/password', async (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
throw ValidationError('Password must be at least 8 characters');
|
||||
|
||||
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 (!user) throw NotFoundError('User not found');
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare(
|
||||
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
|
||||
|
|
@ -240,10 +243,6 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
|
|||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error('[admin] reset-password error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/role
|
||||
|
|
@ -251,24 +250,24 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
|
|||
// changing your own role mid-session.
|
||||
router.put('/users/:id/role', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const { role } = req.body;
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'role must be "admin" or "user"' });
|
||||
throw ValidationError('role must be "admin" or "user"');
|
||||
}
|
||||
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 (!user) throw NotFoundError('User not found');
|
||||
|
||||
if (req.user?.id === targetId) {
|
||||
return res.status(400).json({ error: 'You cannot change your own admin role.' });
|
||||
throw ValidationError('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.' });
|
||||
throw ValidationError('Cannot remove the last admin account.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,14 +304,14 @@ router.put('/users/:id/role', (req: Req, res: Res) => {
|
|||
// PUT /api/admin/users/:id/active
|
||||
router.put('/users/:id/active', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
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 (!user) throw NotFoundError('User not found');
|
||||
if (req.user?.id === targetId) {
|
||||
return res.status(400).json({ error: 'You cannot deactivate your own account.' });
|
||||
throw ValidationError('You cannot deactivate your own account.');
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -334,27 +333,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => {
|
|||
router.put('/users/:id/username', (req: Req, res: Res) => {
|
||||
const { username } = req.body;
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username is required' });
|
||||
throw ValidationError('username is required');
|
||||
}
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 3) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
throw ValidationError('Username must be at least 3 characters');
|
||||
}
|
||||
if (trimmed.length > 50) {
|
||||
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
|
||||
throw ValidationError('Username must be 50 characters or fewer');
|
||||
}
|
||||
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
const taken = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||
.get(trimmed, targetId);
|
||||
if (taken) return res.status(409).json({ error: 'Username already taken' });
|
||||
if (taken) throw ConflictError('Username already taken', 'username');
|
||||
|
||||
const previousUsername = user.username;
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -384,13 +383,12 @@ router.put('/users/:id/username', (req: Req, res: Res) => {
|
|||
// DELETE /api/admin/users/:id
|
||||
router.delete('/users/:id', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
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 === user.id)
|
||||
return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
|
||||
|
||||
const deleteUser = db.transaction(() => {
|
||||
// These three tables have no FK/CASCADE to users — must delete explicitly.
|
||||
|
|
@ -502,28 +500,19 @@ router.get('/bank-sync-config', (req: Req, res: Res) => {
|
|||
// PUT /api/admin/bank-sync-config
|
||||
router.put('/bank-sync-config', (req: Req, res: Res) => {
|
||||
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
|
||||
try {
|
||||
let config = getBankSyncConfig();
|
||||
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);
|
||||
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Migration Rollback ────────────────────────────────────────────────────────
|
||||
router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
||||
const { version } = req.body;
|
||||
if (!version) {
|
||||
return res.status(400).json({ error: 'Version is required' });
|
||||
throw ValidationError('Version is required');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -549,15 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
|||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
// 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' });
|
||||
// Coded rollback errors are intentional user-facing messages; anything
|
||||
// else propagates raw and the terminal handler masks + logs it as a 5xx
|
||||
// (the audit log above keeps err.message either way).
|
||||
if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message);
|
||||
if (err.code === 'ROLLBACK_NOT_SUPPORTED')
|
||||
throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
|
|||
('use strict');
|
||||
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
||||
|
|
@ -18,7 +18,14 @@ const {
|
|||
} = require('../services/authService.cts');
|
||||
const { getImportHistory } = require('../services/spreadsheetImportService.cts');
|
||||
const { logAudit } = require('../services/auditService.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
AuthError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
|
||||
|
||||
// All profile routes require authentication — enforced in server.js.
|
||||
|
|
@ -30,9 +37,7 @@ function dataImportEnabled() {
|
|||
|
||||
function requireDataImportEnabled(req, res, next) {
|
||||
if (!dataImportEnabled()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
|
||||
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
|
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(req.user.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
res.json({
|
||||
...profileResponse(user),
|
||||
|
|
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
|
|||
|
||||
if (username !== undefined) {
|
||||
if (typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username must be a string' });
|
||||
throw ValidationError('username must be a string', 'username');
|
||||
}
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < 3) {
|
||||
return res.status(400).json({ error: 'username must be at least 3 characters' });
|
||||
throw ValidationError('username must be at least 3 characters', 'username');
|
||||
}
|
||||
if (trimmedUsername.length > 50) {
|
||||
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
|
||||
throw ValidationError('username must be 50 characters or fewer', 'username');
|
||||
}
|
||||
const taken = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||
.get(trimmedUsername, req.user.id);
|
||||
if (taken) {
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
throw ConflictError('Username already taken', 'username');
|
||||
}
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
trimmedUsername,
|
||||
|
|
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
|
|||
|
||||
if (displayNameInput !== undefined) {
|
||||
if (typeof displayNameInput !== 'string') {
|
||||
return res.status(400).json({ error: 'display_name must be a string' });
|
||||
throw ValidationError('display_name must be a string', 'display_name');
|
||||
}
|
||||
const trimmed = displayNameInput.trim();
|
||||
if (trimmed.length > 100) {
|
||||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||
throw ValidationError('display_name must be 100 characters or fewer', 'display_name');
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -188,7 +193,7 @@ router.get('/settings', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(req.user.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
// Decrypt push secrets — return masked indicator for token (never the raw value)
|
||||
const pushUrlDecrypted = user.push_url
|
||||
|
|
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
|
|||
|
||||
if (nextEmail !== undefined && nextEmail !== null) {
|
||||
if (typeof nextEmail !== 'string') {
|
||||
return res.status(400).json({ error: 'notification_email must be a string' });
|
||||
throw ValidationError('notification_email must be a string', 'notification_email');
|
||||
}
|
||||
if (nextEmail.trim().length > 255) {
|
||||
return res.status(400).json({ error: 'notification_email is too long' });
|
||||
throw ValidationError('notification_email is too long', 'notification_email');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,29 +355,30 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
|
|||
const { current_password, new_password, confirm_new_password } = req.body;
|
||||
|
||||
if (!current_password) {
|
||||
return res.status(400).json({ error: 'current_password is required' });
|
||||
throw ValidationError('current_password is required', 'current_password');
|
||||
}
|
||||
if (!new_password) {
|
||||
return res.status(400).json({ error: 'new_password is required' });
|
||||
throw ValidationError('new_password is required', 'new_password');
|
||||
}
|
||||
if (!confirm_new_password) {
|
||||
return res.status(400).json({ error: 'confirm_new_password is required' });
|
||||
throw ValidationError('confirm_new_password is required', 'confirm_new_password');
|
||||
}
|
||||
if (new_password !== confirm_new_password) {
|
||||
return res.status(400).json({ error: 'new passwords do not match' });
|
||||
throw ValidationError('new passwords do not match', 'confirm_new_password');
|
||||
}
|
||||
if (new_password.length < 8) {
|
||||
return res.status(400).json({ error: 'new password must be at least 8 characters' });
|
||||
throw ValidationError('new password must be at least 8 characters', 'new_password');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
try {
|
||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, {
|
||||
field: 'current_password',
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
|
|
@ -407,10 +413,6 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
|
|||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error('[profile] change-password error:', err.message);
|
||||
res.status(500).json({ error: 'Password change failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/profile/exports ──────────────────────────────────────────────────
|
||||
|
|
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
|
|||
// Returns the signed-in user's import history.
|
||||
// Delegates to the same service as GET /api/import/history.
|
||||
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
|
||||
try {
|
||||
const history = getImportHistory(req.user.id);
|
||||
res.json({ history });
|
||||
} catch (err) {
|
||||
log.error('[profile] import-history error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load import history' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ const router = require('express').Router();
|
|||
const { log } = require('../utils/logger.cts');
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
|
||||
|
||||
// Boundary adapter: the internal parse/normalize helpers return
|
||||
// { error: <standardized body> } result objects; at the HTTP boundary we
|
||||
// rethrow them as ApiError so the terminal handler emits the same wire shape.
|
||||
function throwStandardized(errBody, status = 400) {
|
||||
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
|
||||
field: errBody.field,
|
||||
});
|
||||
}
|
||||
const {
|
||||
decorateTransaction,
|
||||
ensureManualDataSource,
|
||||
|
|
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
|
||||
if (err.status) {
|
||||
return res
|
||||
.status(err.status)
|
||||
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
|
||||
// 4xx service errors keep their message/code on the wire; anything else is
|
||||
// rethrown raw so the terminal handler masks + logs it as a 5xx.
|
||||
function toTransactionServiceError(err) {
|
||||
if (err.status && err.status < 500) {
|
||||
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
|
||||
field: err.field,
|
||||
});
|
||||
}
|
||||
log.error('[transactions] service error:', err.stack || err.message);
|
||||
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
||||
return err;
|
||||
}
|
||||
|
||||
function emptyBankLedger(enabled, hasConnections = false) {
|
||||
|
|
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
ensureManualDataSource(db, req.user.id);
|
||||
|
||||
const page = parseLimitOffset(req.query);
|
||||
if (page.error) return res.status(400).json(page.error);
|
||||
if (page.error) throwStandardized(page.error);
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
||||
|
|
@ -534,7 +545,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
|
||||
if (req.query[field] !== undefined) {
|
||||
const parsed = parseInteger(req.query[field], field);
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push(`t.${field} = ?`);
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
|
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
|
|||
|
||||
if (req.query.start_date) {
|
||||
const parsed = parseDate(req.query.start_date, 'start_date');
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
|
||||
params.push(parsed.value);
|
||||
}
|
||||
if (req.query.end_date) {
|
||||
const parsed = parseDate(req.query.end_date, 'end_date');
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
|
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
router.get('/bank-ledger', (req: Req, res: Res) => {
|
||||
const config = getBankSyncConfig();
|
||||
const page = parseLimitOffset(req.query);
|
||||
if (page.error) return res.status(400).json(page.error);
|
||||
if (page.error) throwStandardized(page.error);
|
||||
|
||||
const db = getDb();
|
||||
const sources = db
|
||||
|
|
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
.all(req.user.id);
|
||||
|
||||
const filtered = bankLedgerWhere(req.query, req.user.id);
|
||||
if (filtered.error) return res.status(400).json(filtered.error);
|
||||
if (filtered.error) throwStandardized(filtered.error);
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
||||
|
|
@ -758,14 +769,14 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
router.post('/manual', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const directMatchState = rejectDirectMatchState(req.body);
|
||||
if (directMatchState) return res.status(400).json(directMatchState);
|
||||
if (directMatchState) throwStandardized(directMatchState);
|
||||
|
||||
const validation = normalizeTransactionFields(db, req.user.id, req.body);
|
||||
if (validation.error) return res.status(validation.status || 400).json(validation.error);
|
||||
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
||||
const tx = validation.normalized;
|
||||
const source = ensureManualDataSource(db, req.user.id);
|
||||
const state = resolveTransactionState(tx);
|
||||
if (state.error) return res.status(400).json(state.error);
|
||||
if (state.error) throwStandardized(state.error);
|
||||
Object.assign(tx, state);
|
||||
|
||||
const result = db
|
||||
|
|
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
|
|||
router.put('/:id', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const directMatchState = rejectDirectMatchState(req.body);
|
||||
if (directMatchState) return res.status(400).json(directMatchState);
|
||||
if (directMatchState) throwStandardized(directMatchState);
|
||||
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
|
||||
if (validation.error) return res.status(validation.status || 400).json(validation.error);
|
||||
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
||||
const tx = validation.normalized;
|
||||
const state = resolveTransactionState(tx, existing);
|
||||
if (state.error) return res.status(400).json(state.error);
|
||||
if (state.error) throwStandardized(state.error);
|
||||
Object.assign(tx, state);
|
||||
|
||||
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
|
||||
|
|
@ -880,11 +890,10 @@ router.put('/:id', (req: Req, res: Res) => {
|
|||
router.delete('/:id', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
db.transaction(() => {
|
||||
unmatchTransaction(req.user.id, id.value);
|
||||
|
|
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
|
|||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction match failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -915,7 +924,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|||
const result = unmatchTransaction(req.user.id, req.params.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -926,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|||
router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
||||
const matches = req.body?.matches;
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('matches array required');
|
||||
}
|
||||
if (matches.length > 50) {
|
||||
return res
|
||||
|
|
@ -1006,6 +1015,9 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
|||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length > 0 && failed.length === results.length) {
|
||||
// Deliberate hand-rolled body: carries the per-item `results` payload the
|
||||
// client renders; formatError has no extras channel (same as payments'
|
||||
// DUPLICATE_SUSPECTED exception).
|
||||
return res.status(500).json({ error: 'All unmatches failed', results });
|
||||
}
|
||||
res.json({ results, unmatched: results.filter((r) => r.ok).length });
|
||||
|
|
@ -1017,7 +1029,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
|
|||
const result = ignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction ignore failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1027,7 +1039,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
|
|||
const result = unignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction unignore failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1036,11 +1048,10 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|||
try {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const match = findMerchantMatch(
|
||||
db,
|
||||
|
|
@ -1048,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|||
);
|
||||
res.json({ match });
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to look up merchant match');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1057,11 +1068,10 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|||
try {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const match = findMerchantMatch(
|
||||
db,
|
||||
|
|
@ -1078,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|||
display_name: match.display_name,
|
||||
});
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to apply merchant match');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1091,7 +1101,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
|
|||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue