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 fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
||||||
|
const { ValidationError } = require('../utils/apiError.cts');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
||||||
const { enabled } = req.body || {};
|
const { enabled } = req.body || {};
|
||||||
if (typeof enabled !== 'boolean') {
|
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');
|
setSetting('update_check_enabled', enabled ? 'true' : 'false');
|
||||||
res.json({ enabled });
|
res.json({ enabled });
|
||||||
|
|
|
||||||
177
routes/admin.cts
177
routes/admin.cts
|
|
@ -4,6 +4,12 @@ const express = require('express');
|
||||||
const { log } = require('../utils/logger.cts');
|
const { log } = require('../utils/logger.cts');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, rollbackMigration } = require('../db/database.cts');
|
const { getDb, rollbackMigration } = require('../db/database.cts');
|
||||||
|
const {
|
||||||
|
ApiError,
|
||||||
|
ValidationError,
|
||||||
|
NotFoundError,
|
||||||
|
ConflictError,
|
||||||
|
} = require('../utils/apiError.cts');
|
||||||
const {
|
const {
|
||||||
getBankSyncConfig,
|
getBankSyncConfig,
|
||||||
setBankSyncEnabled,
|
setBankSyncEnabled,
|
||||||
|
|
@ -43,6 +49,9 @@ function parseUserId(params) {
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
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) {
|
function sendError(res, err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
|
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
|
||||||
|
|
@ -170,80 +179,70 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
|
||||||
router.post('/users', async (req: Req, res: Res) => {
|
router.post('/users', async (req: Req, res: Res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
if (!username || username.length < 3)
|
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)
|
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 db = getDb();
|
||||||
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
|
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 hash = await hashPassword(password);
|
const result = db
|
||||||
const result = db
|
.prepare(
|
||||||
.prepare(
|
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
|
||||||
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
|
)
|
||||||
)
|
.run(username, hash);
|
||||||
.run(username, hash);
|
|
||||||
|
|
||||||
const created = db
|
const created = db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
|
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
|
||||||
)
|
)
|
||||||
.get(result.lastInsertRowid);
|
.get(result.lastInsertRowid);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
action: 'admin.user.create',
|
action: 'admin.user.create',
|
||||||
entity_type: 'user',
|
entity_type: 'user',
|
||||||
entity_id: created.id,
|
entity_id: created.id,
|
||||||
details: { created_username: username },
|
details: { created_username: username },
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(created);
|
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
|
// PUT /api/admin/users/:id/password
|
||||||
router.put('/users/:id/password', async (req: Req, res: Res) => {
|
router.put('/users/:id/password', async (req: Req, res: Res) => {
|
||||||
const targetId = parseUserId(req.params);
|
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;
|
const { password } = req.body;
|
||||||
if (!password || password.length < 8)
|
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 db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
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);
|
||||||
const hash = await hashPassword(password);
|
db.prepare(
|
||||||
db.prepare(
|
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
|
||||||
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
|
).run(hash, targetId);
|
||||||
).run(hash, targetId);
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
|
||||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
action: 'admin.password.reset',
|
action: 'admin.password.reset',
|
||||||
entity_type: 'user',
|
entity_type: 'user',
|
||||||
entity_id: targetId,
|
entity_id: targetId,
|
||||||
details: { target_username: user.username },
|
details: { target_username: user.username },
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
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
|
// 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.
|
// changing your own role mid-session.
|
||||||
router.put('/users/:id/role', (req: Req, res: Res) => {
|
router.put('/users/:id/role', (req: Req, res: Res) => {
|
||||||
const targetId = parseUserId(req.params);
|
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;
|
const { role } = req.body;
|
||||||
if (!['admin', 'user'].includes(role)) {
|
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 db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
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) {
|
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') {
|
if (user.role === 'admin' && role === 'user') {
|
||||||
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
|
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
|
||||||
if (adminCount <= 1) {
|
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
|
// PUT /api/admin/users/:id/active
|
||||||
router.put('/users/:id/active', (req: Req, res: Res) => {
|
router.put('/users/:id/active', (req: Req, res: Res) => {
|
||||||
const targetId = parseUserId(req.params);
|
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 active = req.body?.active ? 1 : 0;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
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) {
|
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(
|
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) => {
|
router.put('/users/:id/username', (req: Req, res: Res) => {
|
||||||
const { username } = req.body;
|
const { username } = req.body;
|
||||||
if (!username || typeof username !== 'string') {
|
if (!username || typeof username !== 'string') {
|
||||||
return res.status(400).json({ error: 'username is required' });
|
throw ValidationError('username is required');
|
||||||
}
|
}
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
if (trimmed.length < 3) {
|
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) {
|
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);
|
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 db = getDb();
|
||||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
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
|
const taken = db
|
||||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||||
.get(trimmed, targetId);
|
.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;
|
const previousUsername = user.username;
|
||||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
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
|
// DELETE /api/admin/users/:id
|
||||||
router.delete('/users/:id', (req: Req, res: Res) => {
|
router.delete('/users/:id', (req: Req, res: Res) => {
|
||||||
const targetId = parseUserId(req.params);
|
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 db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
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 === user.id)
|
if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
|
||||||
return res.status(400).json({ error: 'You cannot delete your own account.' });
|
|
||||||
|
|
||||||
const deleteUser = db.transaction(() => {
|
const deleteUser = db.transaction(() => {
|
||||||
// These three tables have no FK/CASCADE to users — must delete explicitly.
|
// 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
|
// PUT /api/admin/bank-sync-config
|
||||||
router.put('/bank-sync-config', (req: Req, res: Res) => {
|
router.put('/bank-sync-config', (req: Req, res: Res) => {
|
||||||
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
|
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
|
||||||
try {
|
let config = getBankSyncConfig();
|
||||||
let config = getBankSyncConfig();
|
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
|
||||||
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
|
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
|
||||||
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
|
if (sync_days !== undefined) config = setSyncDays(sync_days);
|
||||||
if (sync_days !== undefined) config = setSyncDays(sync_days);
|
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
|
||||||
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
|
res.json(config);
|
||||||
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 ────────────────────────────────────────────────────────
|
// ── Migration Rollback ────────────────────────────────────────────────────────
|
||||||
router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
||||||
const { version } = req.body;
|
const { version } = req.body;
|
||||||
if (!version) {
|
if (!version) {
|
||||||
return res.status(400).json({ error: 'Version is required' });
|
throw ValidationError('Version is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -549,15 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
||||||
user_agent: req.get('user-agent'),
|
user_agent: req.get('user-agent'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (err.code === 'NOT_APPLIED') {
|
// Coded rollback errors are intentional user-facing messages; anything
|
||||||
return res.status(404).json({ error: err.message });
|
// 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 === 'ROLLBACK_NOT_SUPPORTED') {
|
if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message);
|
||||||
return res.status(422).json({ error: err.message });
|
if (err.code === 'ROLLBACK_NOT_SUPPORTED')
|
||||||
}
|
throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
|
||||||
// Unknown failure: the audit log (above) keeps err.message; the response must not.
|
throw err;
|
||||||
log.error('[admin/migrations/rollback]', err);
|
|
||||||
res.status(500).json({ error: 'Rollback failed' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
|
||||||
('use strict');
|
('use strict');
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { log } = require('../utils/logger.cts');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
||||||
|
|
@ -18,7 +18,14 @@ const {
|
||||||
} = require('../services/authService.cts');
|
} = require('../services/authService.cts');
|
||||||
const { getImportHistory } = require('../services/spreadsheetImportService.cts');
|
const { getImportHistory } = require('../services/spreadsheetImportService.cts');
|
||||||
const { logAudit } = require('../services/auditService.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');
|
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
|
||||||
|
|
||||||
// All profile routes require authentication — enforced in server.js.
|
// All profile routes require authentication — enforced in server.js.
|
||||||
|
|
@ -30,9 +37,7 @@ function dataImportEnabled() {
|
||||||
|
|
||||||
function requireDataImportEnabled(req, res, next) {
|
function requireDataImportEnabled(req, res, next) {
|
||||||
if (!dataImportEnabled()) {
|
if (!dataImportEnabled()) {
|
||||||
return res
|
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
|
||||||
.status(403)
|
|
||||||
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
|
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
)
|
)
|
||||||
.get(req.user.id);
|
.get(req.user.id);
|
||||||
|
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) throw NotFoundError('User not found');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...profileResponse(user),
|
...profileResponse(user),
|
||||||
|
|
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) {
|
||||||
if (typeof username !== 'string') {
|
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();
|
const trimmedUsername = username.trim();
|
||||||
if (trimmedUsername.length < 3) {
|
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) {
|
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
|
const taken = db
|
||||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||||
.get(trimmedUsername, req.user.id);
|
.get(trimmedUsername, req.user.id);
|
||||||
if (taken) {
|
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(
|
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||||
trimmedUsername,
|
trimmedUsername,
|
||||||
|
|
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
|
||||||
|
|
||||||
if (displayNameInput !== undefined) {
|
if (displayNameInput !== undefined) {
|
||||||
if (typeof displayNameInput !== 'string') {
|
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();
|
const trimmed = displayNameInput.trim();
|
||||||
if (trimmed.length > 100) {
|
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(
|
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);
|
.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)
|
// Decrypt push secrets — return masked indicator for token (never the raw value)
|
||||||
const pushUrlDecrypted = user.push_url
|
const pushUrlDecrypted = user.push_url
|
||||||
|
|
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
|
||||||
|
|
||||||
if (nextEmail !== undefined && nextEmail !== null) {
|
if (nextEmail !== undefined && nextEmail !== null) {
|
||||||
if (typeof nextEmail !== 'string') {
|
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) {
|
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,67 +355,64 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
|
||||||
const { current_password, new_password, confirm_new_password } = req.body;
|
const { current_password, new_password, confirm_new_password } = req.body;
|
||||||
|
|
||||||
if (!current_password) {
|
if (!current_password) {
|
||||||
return res.status(400).json({ error: 'current_password is required' });
|
throw ValidationError('current_password is required', 'current_password');
|
||||||
}
|
}
|
||||||
if (!new_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) {
|
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) {
|
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) {
|
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 db = getDb();
|
||||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
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);
|
||||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
if (!valid) {
|
||||||
if (!valid) {
|
throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, {
|
||||||
return res.status(401).json({ error: 'current password is incorrect' });
|
field: 'current_password',
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const hash = await hashPassword(new_password);
|
const hash = await hashPassword(new_password);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET password_hash = ?, must_change_password = 0,
|
SET password_hash = ?, must_change_password = 0,
|
||||||
last_password_change_at = datetime('now'),
|
last_password_change_at = datetime('now'),
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
).run(hash, req.user.id);
|
).run(hash, req.user.id);
|
||||||
|
|
||||||
// Invalidate all other sessions for this user
|
// Invalidate all other sessions for this user
|
||||||
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
invalidateOtherSessions(req.user.id, currentSessionId);
|
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||||
|
|
||||||
// Rotate the current session ID for security
|
// Rotate the current session ID for security
|
||||||
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||||
if (newSessionId) {
|
if (newSessionId) {
|
||||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
|
||||||
user_id: req.user.id,
|
|
||||||
action: 'password.change',
|
|
||||||
ip_address: req.ip,
|
|
||||||
user_agent: req.get('user-agent'),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
log.error('[profile] change-password error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Password change failed' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
user_id: req.user.id,
|
||||||
|
action: 'password.change',
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.get('user-agent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /api/profile/exports ──────────────────────────────────────────────────
|
// ── GET /api/profile/exports ──────────────────────────────────────────────────
|
||||||
|
|
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
|
||||||
// Returns the signed-in user's import history.
|
// Returns the signed-in user's import history.
|
||||||
// Delegates to the same service as GET /api/import/history.
|
// Delegates to the same service as GET /api/import/history.
|
||||||
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
|
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
|
||||||
try {
|
const history = getImportHistory(req.user.id);
|
||||||
const history = getImportHistory(req.user.id);
|
res.json({ history });
|
||||||
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@ const router = require('express').Router();
|
||||||
const { log } = require('../utils/logger.cts');
|
const { log } = require('../utils/logger.cts');
|
||||||
const { getDb } = require('../db/database.cts');
|
const { getDb } = require('../db/database.cts');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter.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 {
|
const {
|
||||||
decorateTransaction,
|
decorateTransaction,
|
||||||
ensureManualDataSource,
|
ensureManualDataSource,
|
||||||
|
|
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
|
// 4xx service errors keep their message/code on the wire; anything else is
|
||||||
if (err.status) {
|
// rethrown raw so the terminal handler masks + logs it as a 5xx.
|
||||||
return res
|
function toTransactionServiceError(err) {
|
||||||
.status(err.status)
|
if (err.status && err.status < 500) {
|
||||||
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
|
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
|
||||||
|
field: err.field,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
log.error('[transactions] service error:', err.stack || err.message);
|
return err;
|
||||||
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyBankLedger(enabled, hasConnections = false) {
|
function emptyBankLedger(enabled, hasConnections = false) {
|
||||||
|
|
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
ensureManualDataSource(db, req.user.id);
|
ensureManualDataSource(db, req.user.id);
|
||||||
|
|
||||||
const page = parseLimitOffset(req.query);
|
const page = parseLimitOffset(req.query);
|
||||||
if (page.error) return res.status(400).json(page.error);
|
if (page.error) throwStandardized(page.error);
|
||||||
|
|
||||||
const SORT_COLUMNS = {
|
const SORT_COLUMNS = {
|
||||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
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']) {
|
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
|
||||||
if (req.query[field] !== undefined) {
|
if (req.query[field] !== undefined) {
|
||||||
const parsed = parseInteger(req.query[field], field);
|
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} = ?`);
|
where.push(`t.${field} = ?`);
|
||||||
params.push(parsed.value);
|
params.push(parsed.value);
|
||||||
}
|
}
|
||||||
|
|
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
|
|
||||||
if (req.query.start_date) {
|
if (req.query.start_date) {
|
||||||
const parsed = parseDate(req.query.start_date, '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)) >= ?');
|
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
|
||||||
params.push(parsed.value);
|
params.push(parsed.value);
|
||||||
}
|
}
|
||||||
if (req.query.end_date) {
|
if (req.query.end_date) {
|
||||||
const parsed = parseDate(req.query.end_date, '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)) <= ?');
|
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
|
||||||
params.push(parsed.value);
|
params.push(parsed.value);
|
||||||
}
|
}
|
||||||
|
|
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
router.get('/bank-ledger', (req: Req, res: Res) => {
|
router.get('/bank-ledger', (req: Req, res: Res) => {
|
||||||
const config = getBankSyncConfig();
|
const config = getBankSyncConfig();
|
||||||
const page = parseLimitOffset(req.query);
|
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 db = getDb();
|
||||||
const sources = db
|
const sources = db
|
||||||
|
|
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
||||||
.all(req.user.id);
|
.all(req.user.id);
|
||||||
|
|
||||||
const filtered = bankLedgerWhere(req.query, 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 = {
|
const SORT_COLUMNS = {
|
||||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
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) => {
|
router.post('/manual', (req: Req, res: Res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const directMatchState = rejectDirectMatchState(req.body);
|
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);
|
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 tx = validation.normalized;
|
||||||
const source = ensureManualDataSource(db, req.user.id);
|
const source = ensureManualDataSource(db, req.user.id);
|
||||||
const state = resolveTransactionState(tx);
|
const state = resolveTransactionState(tx);
|
||||||
if (state.error) return res.status(400).json(state.error);
|
if (state.error) throwStandardized(state.error);
|
||||||
Object.assign(tx, state);
|
Object.assign(tx, state);
|
||||||
|
|
||||||
const result = db
|
const result = db
|
||||||
|
|
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
|
||||||
router.put('/:id', (req: Req, res: Res) => {
|
router.put('/:id', (req: Req, res: Res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const directMatchState = rejectDirectMatchState(req.body);
|
const directMatchState = rejectDirectMatchState(req.body);
|
||||||
if (directMatchState) return res.status(400).json(directMatchState);
|
if (directMatchState) throwStandardized(directMatchState);
|
||||||
|
|
||||||
const id = parseInteger(req.params.id, 'id');
|
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);
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
if (!existing)
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
|
||||||
|
|
||||||
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
|
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 tx = validation.normalized;
|
||||||
const state = resolveTransactionState(tx, existing);
|
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);
|
Object.assign(tx, state);
|
||||||
|
|
||||||
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
|
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) => {
|
router.delete('/:id', (req: Req, res: Res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseInteger(req.params.id, 'id');
|
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);
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
if (!existing)
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
unmatchTransaction(req.user.id, id.value);
|
unmatchTransaction(req.user.id, id.value);
|
||||||
|
|
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
|
||||||
);
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} 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);
|
const result = unmatchTransaction(req.user.id, req.params.id);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} 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) => {
|
router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
||||||
const matches = req.body?.matches;
|
const matches = req.body?.matches;
|
||||||
if (!Array.isArray(matches) || matches.length === 0) {
|
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) {
|
if (matches.length > 50) {
|
||||||
return res
|
return res
|
||||||
|
|
@ -1006,6 +1015,9 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.ok);
|
const failed = results.filter((r) => !r.ok);
|
||||||
if (failed.length > 0 && failed.length === results.length) {
|
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 });
|
return res.status(500).json({ error: 'All unmatches failed', results });
|
||||||
}
|
}
|
||||||
res.json({ results, unmatched: results.filter((r) => r.ok).length });
|
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);
|
const result = ignoreTransaction(req.user.id, req.params.id);
|
||||||
res.json(result.transaction);
|
res.json(result.transaction);
|
||||||
} catch (err) {
|
} 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);
|
const result = unignoreTransaction(req.user.id, req.params.id);
|
||||||
res.json(result.transaction);
|
res.json(result.transaction);
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseInteger(req.params.id, 'id');
|
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);
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
if (!existing)
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
|
||||||
|
|
||||||
const match = findMerchantMatch(
|
const match = findMerchantMatch(
|
||||||
db,
|
db,
|
||||||
|
|
@ -1048,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
||||||
);
|
);
|
||||||
res.json({ match });
|
res.json({ match });
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseInteger(req.params.id, 'id');
|
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);
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
if (!existing)
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
|
||||||
|
|
||||||
const match = findMerchantMatch(
|
const match = findMerchantMatch(
|
||||||
db,
|
db,
|
||||||
|
|
@ -1078,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
||||||
display_name: match.display_name,
|
display_name: match.display_name,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
|
throw toTransactionServiceError(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue