const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { pool, initDb } = require('./db'); const { v4: uuidv4 } = require('uuid'); const nodemailer = require('nodemailer'); const app = express(); const PORT = process.env.PORT || 3001; const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123'; app.use(cors()); app.use(bodyParser.json()); // --- EMAIL SERVICE & SCHEDULER --- // Function to send email async function sendEmailToUsers(subject, body) { try { const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1'); if (!settings.length || !settings[0].smtp_config) { console.log('No SMTP config found, skipping email.'); return; } const config = settings[0].smtp_config; // Basic validation if (!config.host || !config.user || !config.pass) return; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, // true for 465, false for other ports auth: { user: config.user, pass: config.pass, }, }); // Get users who opted in const [users] = await pool.query('SELECT email FROM users WHERE receive_alerts = TRUE AND email IS NOT NULL AND email != ""'); if (users.length === 0) return; const bccList = users.map(u => u.email).join(','); await transporter.sendMail({ from: config.fromEmail || config.user, bcc: bccList, // Blind copy to all users subject: subject, text: body, // Plain text for now // html: body // Could add HTML support later }); console.log(`Alert sent to ${users.length} users.`); } catch (error) { console.error('Email sending failed:', error.message); } } // Simple Scheduler (Simulating Cron) // In production, use 'node-cron' or similar. Here we use setInterval for simplicity in this environment setInterval(async () => { try { const now = new Date(); const currentHour = now.getHours(); // 1. Get Active Alerts for this hour const [alerts] = await pool.query('SELECT * FROM alerts WHERE active = TRUE AND send_hour = ?', [currentHour]); for (const alert of alerts) { let shouldSend = false; const today = new Date(); today.setHours(0,0,0,0); // Determine Target Date based on logic // "before_next_month": Check if today is (LastDayOfMonth - days_offset) // "after_current_month": Check if today is (FirstDayOfMonth + days_offset) if (alert.offset_type === 'before_next_month') { const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1); const targetDate = new Date(nextMonth); targetDate.setDate(targetDate.getDate() - alert.days_offset); if (today.getTime() === targetDate.getTime()) shouldSend = true; } else if (alert.offset_type === 'after_current_month') { const thisMonth = new Date(today.getFullYear(), today.getMonth(), 1); const targetDate = new Date(thisMonth); targetDate.setDate(targetDate.getDate() + alert.days_offset); if (today.getTime() === targetDate.getTime()) shouldSend = true; } // Check if already sent today (to prevent double send if interval restarts) if (shouldSend) { const lastSent = alert.last_sent ? new Date(alert.last_sent) : null; if (lastSent && lastSent.toDateString() === today.toDateString()) { shouldSend = false; } } if (shouldSend) { console.log(`Triggering alert: ${alert.subject}`); await sendEmailToUsers(alert.subject, alert.body); await pool.query('UPDATE alerts SET last_sent = NOW() WHERE id = ?', [alert.id]); } } } catch (e) { console.error("Scheduler error:", e); } }, 60 * 60 * 1000); // Check every hour (approx) // --- MIDDLEWARE --- const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN if (!token) return res.sendStatus(401); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.sendStatus(403); req.user = user; next(); }); }; const requireAdmin = (req, res, next) => { if (req.user && req.user.role === 'admin') { next(); } else { res.status(403).json({ message: 'Access denied: Admins only' }); } }; // --- AUTH ROUTES --- app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; try { const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' }); const user = users[0]; const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' }); const token = jwt.sign( { id: user.id, email: user.email, role: user.role, familyId: user.family_id }, JWT_SECRET, { expiresIn: '24h' } ); res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role, familyId: user.family_id, receiveAlerts: !!user.receive_alerts } }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- PROFILE ROUTES (Self-service) --- app.put('/api/profile', authenticateToken, async (req, res) => { const userId = req.user.id; const { name, phone, password, receiveAlerts } = req.body; try { let query = 'UPDATE users SET name = ?, phone = ?, receive_alerts = ?'; let params = [name, phone, receiveAlerts]; if (password && password.trim() !== '') { const hashedPassword = await bcrypt.hash(password, 10); query += ', password_hash = ?'; params.push(hashedPassword); } query += ' WHERE id = ?'; params.push(userId); await pool.query(query, params); // Return updated user info const [updatedUser] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users WHERE id = ?', [userId]); res.json({ success: true, user: { id: updatedUser[0].id, email: updatedUser[0].email, name: updatedUser[0].name, role: updatedUser[0].role, phone: updatedUser[0].phone, familyId: updatedUser[0].family_id, receiveAlerts: !!updatedUser[0].receive_alerts } }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- SETTINGS ROUTES --- app.get('/api/settings', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1'); if (rows.length > 0) { res.json({ condoName: rows[0].condo_name, defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota), currentYear: rows[0].current_year, smtpConfig: rows[0].smtp_config || {} }); } else { res.status(404).json({ message: 'Settings not found' }); } } catch (e) { res.status(500).json({ error: e.message }); } }); app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => { const { condoName, defaultMonthlyQuota, currentYear, smtpConfig } = req.body; try { await pool.query( 'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ?, smtp_config = ? WHERE id = 1', [condoName, defaultMonthlyQuota, currentYear, JSON.stringify(smtpConfig)] ); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/years', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC'); const [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1'); const years = new Set(rows.map(r => r.for_year)); if (settings.length > 0) { years.add(settings[0].current_year); } res.json(Array.from(years).sort((a, b) => b - a)); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- ALERTS ROUTES --- app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM alerts'); res.json(rows.map(r => ({ id: r.id, subject: r.subject, body: r.body, daysOffset: r.days_offset, offsetType: r.offset_type, sendHour: r.send_hour, active: !!r.active, lastSent: r.last_sent }))); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { const { subject, body, daysOffset, offsetType, sendHour, active } = req.body; const id = uuidv4(); try { await pool.query( 'INSERT INTO alerts (id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, subject, body, daysOffset, offsetType, sendHour, active] ); res.json({ id, subject, body, daysOffset, offsetType, sendHour, active }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { const { id } = req.params; const { subject, body, daysOffset, offsetType, sendHour, active } = req.body; try { await pool.query( 'UPDATE alerts SET subject = ?, body = ?, days_offset = ?, offset_type = ?, send_hour = ?, active = ? WHERE id = ?', [subject, body, daysOffset, offsetType, sendHour, active, id] ); res.json({ id, subject, body, daysOffset, offsetType, sendHour, active }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { try { await pool.query('DELETE FROM alerts WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- FAMILIES ROUTES --- app.get('/api/families', authenticateToken, async (req, res) => { try { let query = ` SELECT f.*, (SELECT COALESCE(SUM(amount), 0) FROM payments WHERE family_id = f.id) as total_paid FROM families f `; let params = []; // Permission Logic: Admin and Poweruser see all. Users see only their own. const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser'; if (!isPrivileged) { if (!req.user.familyId) return res.json([]); // User not linked to a family query += ' WHERE f.id = ?'; params.push(req.user.familyId); } const [rows] = await pool.query(query, params); const families = rows.map(r => ({ id: r.id, name: r.name, unitNumber: r.unit_number, contactEmail: r.contact_email, balance: 0 })); res.json(families); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => { const { name, unitNumber, contactEmail } = req.body; const id = uuidv4(); try { await pool.query( 'INSERT INTO families (id, name, unit_number, contact_email) VALUES (?, ?, ?, ?)', [id, name, unitNumber, contactEmail] ); res.json({ id, name, unitNumber, contactEmail, balance: 0 }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { const { id } = req.params; const { name, unitNumber, contactEmail } = req.body; try { await pool.query( 'UPDATE families SET name = ?, unit_number = ?, contact_email = ? WHERE id = ?', [name, unitNumber, contactEmail, id] ); res.json({ id, name, unitNumber, contactEmail }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM families WHERE id = ?', [id]); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- PAYMENTS ROUTES --- app.get('/api/payments', authenticateToken, async (req, res) => { const { familyId } = req.query; try { // Permission Logic const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser'; if (!isPrivileged) { if (familyId && familyId !== req.user.familyId) { return res.status(403).json({ message: 'Forbidden' }); } if (!familyId) { // If no familyId requested, user sees only their own const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]); return res.json(rows.map(mapPaymentRow)); } } let query = 'SELECT * FROM payments'; let params = []; if (familyId) { query += ' WHERE family_id = ?'; params.push(familyId); } const [rows] = await pool.query(query, params); res.json(rows.map(mapPaymentRow)); } catch (e) { res.status(500).json({ error: e.message }); } }); function mapPaymentRow(r) { return { id: r.id, familyId: r.family_id, amount: parseFloat(r.amount), datePaid: r.date_paid, forMonth: r.for_month, forYear: r.for_year, notes: r.notes }; } app.post('/api/payments', authenticateToken, async (req, res) => { const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body; // Basic security: // Admin: Can add for anyone // Poweruser: READ ONLY (cannot add) // User: Cannot add (usually) if (req.user.role !== 'admin') { return res.status(403).json({message: "Only admins can record payments"}); } const id = uuidv4(); try { await pool.query( 'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, familyId, amount, new Date(datePaid), forMonth, forYear, notes] ); res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- USERS ROUTES --- app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => { try { const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users'); res.json(rows.map(r => ({ id: r.id, email: r.email, name: r.name, role: r.role, phone: r.phone, familyId: r.family_id, receiveAlerts: !!r.receive_alerts }))); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => { const { email, password, name, role, familyId, phone, receiveAlerts } = req.body; try { const hashedPassword = await bcrypt.hash(password, 10); const id = uuidv4(); await pool.query( 'INSERT INTO users (id, email, password_hash, name, role, family_id, phone, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, email, hashedPassword, name, role || 'user', familyId || null, phone, receiveAlerts] ); res.json({ success: true, id }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { const { id } = req.params; const { email, role, familyId, name, phone, password, receiveAlerts } = req.body; try { // Prepare update query dynamically based on whether password is being changed let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?, receive_alerts = ?'; let params = [email, role, familyId || null, name, phone, receiveAlerts]; if (password && password.trim() !== '') { const hashedPassword = await bcrypt.hash(password, 10); query += ', password_hash = ?'; params.push(hashedPassword); } query += ' WHERE id = ?'; params.push(id); await pool.query(query, params); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { try { await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Start Server initDb().then(() => { app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); });