feat: Add email configuration and alert system

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.
This commit is contained in:
2025-12-06 23:01:02 +01:00
parent 89f4c9946b
commit 26fc451871
13 changed files with 1167 additions and 223 deletions

View File

@@ -4,7 +4,8 @@ const bodyParser = require('body-parser');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool, initDb } = require('./db');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -13,6 +14,107 @@ 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) => {
@@ -61,7 +163,8 @@ app.post('/api/auth/login', async (req, res) => {
email: user.email,
name: user.name,
role: user.role,
familyId: user.family_id
familyId: user.family_id,
receiveAlerts: !!user.receive_alerts
}
});
} catch (e) {
@@ -69,6 +172,48 @@ app.post('/api/auth/login', async (req, res) => {
}
});
// --- 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) => {
@@ -78,7 +223,8 @@ app.get('/api/settings', authenticateToken, async (req, res) => {
res.json({
condoName: rows[0].condo_name,
defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota),
currentYear: rows[0].current_year
currentYear: rows[0].current_year,
smtpConfig: rows[0].smtp_config || {}
});
} else {
res.status(404).json({ message: 'Settings not found' });
@@ -89,11 +235,11 @@ app.get('/api/settings', authenticateToken, async (req, res) => {
});
app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
const { condoName, defaultMonthlyQuota, currentYear } = req.body;
const { condoName, defaultMonthlyQuota, currentYear, smtpConfig } = req.body;
try {
await pool.query(
'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ? WHERE id = 1',
[condoName, defaultMonthlyQuota, currentYear]
'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) {
@@ -117,6 +263,64 @@ app.get('/api/years', authenticateToken, async (req, res) => {
}
});
// --- 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) => {
@@ -264,14 +468,15 @@ app.post('/api/payments', authenticateToken, async (req, res) => {
app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id FROM users');
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
familyId: r.family_id,
receiveAlerts: !!r.receive_alerts
})));
} catch (e) {
res.status(500).json({ error: e.message });
@@ -279,13 +484,13 @@ app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
});
app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
const { email, password, name, role, familyId, phone } = req.body;
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) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, email, hashedPassword, name, role || 'user', familyId || null, phone]
'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) {
@@ -295,12 +500,12 @@ app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
const { id } = req.params;
const { email, role, familyId, name, phone, password } = req.body;
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 = ?';
let params = [email, role, familyId || null, name, phone];
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);