Introduces SMTP configuration settings and alert definitions to enable automated email notifications. This includes new types for `SmtpConfig` and `AlertDefinition`, and integrates these into the settings page and mock database. Adds styling for select elements and scrollbar hiding in the main HTML. Updates mock database logic to potentially support local development without a backend.
540 lines
17 KiB
JavaScript
540 lines
17 KiB
JavaScript
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}`);
|
|
});
|
|
}); |