BillTracker/routes/profile.js

241 lines
9.1 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
'use strict';
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { getDb, getSetting } = require('../db/database');
const { hashPassword } = require('../services/authService');
const { getImportHistory } = require('../services/spreadsheetImportService');
const { passwordLimiter } = require('../middleware/rateLimiter');
// All profile routes require authentication — enforced in server.js.
// req.user is always the signed-in user; user_id is never accepted from the body.
// ── GET /api/profile ──────────────────────────────────────────────────────────
// Returns safe profile data for the signed-in user.
// Never returns password_hash, session tokens, or secrets.
router.get('/', (req, res) => {
const db = getDb();
const user = db.prepare(`
SELECT id, username, display_name, role,
first_login, created_at, updated_at,
last_password_change_at,
notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue
FROM users WHERE id = ?
`).get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
id: user.id,
username: user.username,
display_name: user.display_name || null,
role: user.role,
created_at: user.created_at,
updated_at: user.updated_at,
last_password_change_at: user.last_password_change_at || null,
first_login: !!user.first_login,
notifications: {
email: user.notification_email || null,
enabled: !!user.notifications_enabled,
notify_3d: !!user.notify_3d,
notify_1d: !!user.notify_1d,
notify_due: !!user.notify_due,
notify_overdue: !!user.notify_overdue,
},
exports: {
user_db: '/api/export/user-db',
user_excel: '/api/export/user-excel',
},
import_history_url: '/api/profile/import-history',
});
});
// ── PATCH /api/profile ────────────────────────────────────────────────────────
// Updates safe profile fields: display_name only.
// Ignores any unknown or restricted fields.
router.patch('/', (req, res) => {
const { display_name } = req.body;
if (display_name !== undefined) {
if (typeof display_name !== 'string') {
return res.status(400).json({ error: 'display_name must be a string' });
}
const trimmed = display_name.trim();
if (trimmed.length > 100) {
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
}
getDb().prepare(
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
).run(trimmed || null, req.user.id);
}
res.json({ success: true });
});
// ── GET /api/profile/settings ─────────────────────────────────────────────────
// Returns user-owned notification preferences from the users table.
// Does not return admin/global/SMTP settings.
router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare(`
SELECT notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue
FROM users WHERE id = ?
`).get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
notification_email: user.notification_email || null,
notifications_enabled: !!user.notifications_enabled,
notify_3d: !!user.notify_3d,
notify_1d: !!user.notify_1d,
notify_due: !!user.notify_due,
notify_overdue: !!user.notify_overdue,
});
});
// ── PATCH /api/profile/settings ───────────────────────────────────────────────
// Updates user-owned notification preferences only.
// Cannot modify global/admin/SMTP settings through this endpoint.
router.patch('/settings', (req, res) => {
const db = getDb();
const {
notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue,
} = req.body;
if (notification_email !== undefined && notification_email !== null) {
if (typeof notification_email !== 'string') {
return res.status(400).json({ error: 'notification_email must be a string' });
}
if (notification_email.trim().length > 255) {
return res.status(400).json({ error: 'notification_email is too long' });
}
}
const current = db.prepare(`
SELECT notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue
FROM users WHERE id = ?
`).get(req.user.id);
const emailVal = notification_email !== undefined
? (notification_email ? notification_email.trim() || null : null)
: current.notification_email;
const boolVal = (incoming, fallback) =>
incoming !== undefined ? (incoming ? 1 : 0) : fallback;
db.prepare(`
UPDATE users SET
notification_email = ?,
notifications_enabled = ?,
notify_3d = ?,
notify_1d = ?,
notify_due = ?,
notify_overdue = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
emailVal,
boolVal(notifications_enabled, current.notifications_enabled),
boolVal(notify_3d, current.notify_3d),
boolVal(notify_1d, current.notify_1d),
boolVal(notify_due, current.notify_due),
boolVal(notify_overdue, current.notify_overdue),
req.user.id,
);
res.json({ success: true });
});
// ── POST /api/profile/change-password ─────────────────────────────────────────
// Changes the signed-in user's password.
// Always requires: current_password, new_password, confirm_new_password.
// Never bypasses current_password verification regardless of must_change_password.
// Never accepts user_id from the request body.
router.post('/change-password', passwordLimiter, async (req, res) => {
const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) {
return res.status(400).json({ error: 'current_password is required' });
}
if (!new_password) {
return res.status(400).json({ error: 'new_password is required' });
}
if (!confirm_new_password) {
return res.status(400).json({ error: 'confirm_new_password is required' });
}
if (new_password !== confirm_new_password) {
return res.status(400).json({ error: 'new passwords do not match' });
}
if (new_password.length < 8) {
return res.status(400).json({ error: 'new password must be at least 8 characters' });
}
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' });
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'current password is incorrect' });
}
const hash = await hashPassword(new_password);
db.prepare(`
UPDATE users
SET password_hash = ?, must_change_password = 0,
last_password_change_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ?
`).run(hash, req.user.id);
res.json({ success: true });
});
// ── GET /api/profile/exports ──────────────────────────────────────────────────
// Returns metadata about available user export capabilities.
// Does not trigger any download; links point to the actual export endpoints.
router.get('/exports', (req, res) => {
res.json({
exports: [
{
id: 'user_db',
label: 'SQLite database export',
description: "Your bills, categories, payments, and notes as a portable SQLite file",
url: '/api/export/user-db',
method: 'GET',
},
{
id: 'user_excel',
label: 'Excel databook export',
description: "Your data as an Excel workbook with multiple sheets",
url: '/api/export/user-excel',
method: 'GET',
},
],
});
});
// ── GET /api/profile/import-history ──────────────────────────────────────────
// Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history.
router.get('/import-history', (req, res) => {
try {
const history = getImportHistory(req.user.id);
res.json({ history });
} catch (err) {
console.error('[profile] import-history error:', err.message);
res.status(500).json({ error: 'Failed to load import history' });
}
});
module.exports = router;