Files
Condopay/server/server.js
frakarr fd107c1ef8 feat: Enhance condo and family data models
Adds new fields for detailed address information and notes to the Condo and Family types.
Updates database schema and server API endpoints to support these new fields, improving data richness for location and specific family/condo details.
2025-12-07 16:10:33 +01:00

439 lines
20 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 & SCHEDULER (Same as before) ---
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) return;
const config = settings[0].smtp_config;
if (!config.host || !config.user || !config.pass) return;
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.pass },
});
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,
subject: subject,
text: body,
});
} catch (error) { console.error('Email error:', error.message); }
}
// --- MIDDLEWARE ---
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => {
// Return 401 for token errors (expired/invalid) to trigger frontend logout
if (err) {
console.error("Token verification failed:", err.message);
return res.sendStatus(401);
}
req.user = user;
next();
});
};
const requireAdmin = (req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
console.warn(`Access denied for user ${req.user?.email} with role ${req.user?.role}`);
res.status(403).json({ message: 'Access denied: Admins only' });
}
};
// --- AUTH ---
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' });
// Ensure role is captured correctly from DB
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 ---
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);
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: { ...updatedUser[0], receiveAlerts: !!updatedUser[0].receive_alerts } });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- SETTINGS ---
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({ 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 { currentYear, smtpConfig } = req.body;
try {
await pool.query('UPDATE settings SET current_year = ?, smtp_config = ? WHERE id = 1', [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 }); }
});
// --- CONDOS ---
app.get('/api/condos', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM condos');
res.json(rows.map(r => ({
id: r.id,
name: r.name,
address: r.address,
streetNumber: r.street_number,
city: r.city,
province: r.province,
zipCode: r.zip_code,
notes: r.notes,
iban: r.iban,
defaultMonthlyQuota: parseFloat(r.default_monthly_quota),
image: r.image
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota } = req.body;
const id = uuidv4();
try {
await pool.query(
'INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota]
);
res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota } = req.body;
try {
await pool.query(
'UPDATE condos SET name = ?, address = ?, street_number = ?, city = ?, province = ?, zip_code = ?, notes = ?, default_monthly_quota = ? WHERE id = ?',
[name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, req.params.id]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM condos WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- FAMILIES ---
app.get('/api/families', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = `SELECT f.* FROM families f`;
let params = [];
// Authorization/Filtering logic
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
// Regular user: can only see their own family
if (!req.user.familyId) return res.json([]);
query += ' WHERE f.id = ?';
params.push(req.user.familyId);
} else {
// Admin: If condoId provided, filter by it.
if (condoId) {
query += ' WHERE f.condo_id = ?';
params.push(condoId);
}
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({
id: r.id,
condoId: r.condo_id,
name: r.name,
unitNumber: r.unit_number,
stair: r.stair,
floor: r.floor,
notes: r.notes,
contactEmail: r.contact_email,
customMonthlyQuota: r.custom_monthly_quota ? parseFloat(r.custom_monthly_quota) : undefined,
balance: 0
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, stair, floor, notes, contactEmail, condoId, customMonthlyQuota } = req.body;
const id = uuidv4();
try {
await pool.query(
'INSERT INTO families (id, condo_id, name, unit_number, stair, floor, notes, contact_email, custom_monthly_quota) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null]
);
res.json({ id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota } = req.body;
try {
await pool.query(
'UPDATE families SET name = ?, unit_number = ?, stair = ?, floor = ?, notes = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?',
[name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null, req.params.id]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM families WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- NOTICES ---
app.get('/api/notices', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT * FROM notices';
let params = [];
if (condoId) {
query += ' WHERE condo_id = ?';
params.push(condoId);
}
query += ' ORDER BY date DESC';
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, content: r.content, type: r.type, link: r.link, date: r.date, active: !!r.active })));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/notices', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, content, type, link, active } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO notices (id, condo_id, title, content, type, link, active, date) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())', [id, condoId, title, content, type, link, active]);
res.json({ id, condoId, title, content, type, link, active });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => {
const { title, content, type, link, active } = req.body;
try {
await pool.query('UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ? WHERE id = ?', [title, content, type, link, active, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM notices WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/notices/:id/read', authenticateToken, async (req, res) => {
const { userId } = req.body;
try {
await pool.query('INSERT IGNORE INTO notice_reads (user_id, notice_id, read_at) VALUES (?, ?, NOW())', [userId, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/:id/reads', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM notice_reads WHERE notice_id = ?', [req.params.id]);
res.json(rows.map(r => ({ userId: r.user_id, noticeId: r.notice_id, readAt: r.read_at })));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/unread', authenticateToken, async (req, res) => {
const { userId, condoId } = req.query;
try {
const [rows] = await pool.query(`
SELECT n.* FROM notices n
LEFT JOIN notice_reads nr ON n.id = nr.notice_id AND nr.user_id = ?
WHERE n.condo_id = ? AND n.active = TRUE AND nr.read_at IS NULL
ORDER BY n.date DESC
`, [userId, condoId]);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, content: r.content, type: r.type, link: r.link, date: r.date, active: !!r.active })));
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- PAYMENTS ---
app.get('/api/payments', authenticateToken, async (req, res) => {
const { familyId } = req.query;
try {
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) {
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;
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 ---
app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts FROM users u';
let params = [];
// Filter users by condo.
// Logic: Users belong to families, families belong to condos.
if (condoId) {
query += ' LEFT JOIN families f ON u.family_id = f.id WHERE f.condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
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 { email, role, familyId, name, phone, password, receiveAlerts } = req.body;
try {
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(req.params.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 }); }
});
// --- ALERTS ---
app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT * FROM alerts';
let params = [];
if (condoId) {
query += ' WHERE condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_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 { condoId, subject, body, daysOffset, offsetType, sendHour, active } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO alerts (id, condo_id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, subject, body, daysOffset, offsetType, sendHour, active]);
res.json({ id, condoId, 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 { 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, req.params.id]);
res.json({ success: true });
} 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 }); }
});
initDb().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});