'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;