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()); // Increased limit to support base64 file uploads for tickets app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); // --- EMAIL HELPERS --- async function getTransporter() { const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1'); if (!settings.length || !settings[0].smtp_config) return null; const config = settings[0].smtp_config; if (!config.host || !config.user || !config.pass) return null; return { transporter: nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: { user: config.user, pass: config.pass }, }), from: config.fromEmail || config.user }; } async function sendEmailToUsers(subject, body) { try { const setup = await getTransporter(); if (!setup) return; 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 setup.transporter.sendMail({ from: setup.from, bcc: bccList, subject: subject, text: body, }); } catch (error) { console.error('Email error:', error.message); } } async function sendDirectEmail(to, subject, body) { try { const setup = await getTransporter(); if (!setup) return; await setup.transporter.sendMail({ from: setup.from, to: to, subject: subject, text: body }); console.log(`Direct email sent to ${to}`); } catch (error) { console.error('Direct 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) => { // Allow both 'admin' and 'poweruser' to access administrative routes if (req.user && (req.user.role === 'admin' || req.user.role === 'poweruser')) { next(); } else { console.warn(`Access denied for user ${req.user?.email} with role ${req.user?.role}`); res.status(403).json({ message: 'Access denied: Privileged users 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 || {}, features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true } }); } 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, features } = req.body; try { await pool.query( 'UPDATE settings SET current_year = ?, smtp_config = ?, features = ? WHERE id = 1', [currentYear, JSON.stringify(smtpConfig), JSON.stringify(features)] ); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // SMTP TEST app.post('/api/settings/smtp-test', authenticateToken, requireAdmin, async (req, res) => { const config = req.body; // Expects SmtpConfig object const userEmail = req.user.email; try { if (!config.host || !config.user || !config.pass) { return res.status(400).json({ message: 'Parametri SMTP incompleti' }); } const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: { user: config.user, pass: config.pass }, }); // Verify connection await transporter.verify(); // Send Test Email await transporter.sendMail({ from: config.fromEmail || config.user, to: userEmail, subject: 'CondoPay - Test Configurazione SMTP', text: 'Se leggi questo messaggio, la configurazione SMTP รจ corretta.', }); res.json({ success: true }); } catch (e) { console.error("SMTP Test Error", e); res.status(400).json({ message: 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, paypalClientId: r.paypal_client_id, // PayPal 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, paypalClientId } = 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, paypal_client_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId] ); res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId }); } 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, paypalClientId } = req.body; try { await pool.query( 'UPDATE condos SET name = ?, address = ?, street_number = ?, city = ?, province = ?, zip_code = ?, notes = ?, default_monthly_quota = ?, paypal_client_id = ? WHERE id = ?', [name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, 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 --- // ... (Notices API skipped for brevity, unchanged) ... // (Assume existing Notices API is here as per previous code) // --- PAYMENTS --- // ... (Payments API skipped for brevity, unchanged) ... // --- USERS --- // ... (Users API skipped for brevity, unchanged) ... // --- ALERTS --- // ... (Alerts API skipped for brevity, unchanged) ... // --- TICKETS --- // ... (Tickets API skipped for brevity, unchanged) ... // --- EXTRAORDINARY EXPENSES --- app.get('/api/expenses', authenticateToken, async (req, res) => { const { condoId } = req.query; try { const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE condo_id = ? ORDER BY created_at DESC', [condoId]); res.json(expenses.map(e => ({ id: e.id, condoId: e.condo_id, title: e.title, description: e.description, startDate: e.start_date, endDate: e.end_date, contractorName: e.contractor_name, totalAmount: parseFloat(e.total_amount), createdAt: e.created_at }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/expenses/:id', authenticateToken, async (req, res) => { try { const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]); if (expenses.length === 0) return res.status(404).json({ message: 'Expense not found' }); const expense = expenses[0]; // Fetch Items const [items] = await pool.query('SELECT id, description, amount FROM expense_items WHERE expense_id = ?', [expense.id]); // Fetch Shares const [shares] = await pool.query(` SELECT s.id, s.family_id, f.name as family_name, s.percentage, s.amount_due, s.amount_paid, s.status FROM expense_shares s JOIN families f ON s.family_id = f.id WHERE s.expense_id = ? `, [expense.id]); // Fetch Attachments (light) const [attachments] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [expense.id]); res.json({ id: expense.id, condoId: expense.condo_id, title: expense.title, description: expense.description, startDate: expense.start_date, endDate: expense.end_date, contractorName: expense.contractor_name, totalAmount: parseFloat(expense.total_amount), createdAt: expense.created_at, items: items.map(i => ({ id: i.id, description: i.description, amount: parseFloat(i.amount) })), shares: shares.map(s => ({ id: s.id, familyId: s.family_id, familyName: s.family_name, percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status })), attachments: attachments.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type })) }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => { const { condoId, title, description, startDate, endDate, contractorName, items, shares, attachments } = req.body; const expenseId = uuidv4(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); // Calculate total const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0); // Insert Expense await connection.query( 'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [expenseId, condoId, title, description, startDate, endDate, contractorName, totalAmount] ); // Insert Items for (const item of items) { await connection.query( 'INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), expenseId, item.description, item.amount] ); } // Insert Shares for (const share of shares) { await connection.query( 'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, status) VALUES (?, ?, ?, ?, ?, ?)', [uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID'] ); } // Insert Attachments if (attachments && attachments.length > 0) { for (const att of attachments) { await connection.query( 'INSERT INTO expense_attachments (id, expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', [uuidv4(), expenseId, att.fileName, att.fileType, att.data] ); } } await connection.commit(); res.json({ success: true, id: expenseId }); } catch (e) { await connection.rollback(); console.error(e); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); // Update Expense app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => { const { title, description, startDate, endDate, contractorName, items } = req.body; const expenseId = req.params.id; const connection = await pool.getConnection(); try { await connection.beginTransaction(); // 1. Calculate New Total const newTotalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0); // 2. Update Expense Header await connection.query( 'UPDATE extraordinary_expenses SET title = ?, description = ?, start_date = ?, end_date = ?, contractor_name = ?, total_amount = ? WHERE id = ?', [title, description, startDate, endDate, contractorName, newTotalAmount, expenseId] ); // 3. Update Items (Strategy: Delete old, Insert new) await connection.query('DELETE FROM expense_items WHERE expense_id = ?', [expenseId]); for (const item of items) { await connection.query( 'INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), expenseId, item.description, item.amount] ); } // 4. Update Shares (Recalculate Due Amount based on stored percentage vs new Total) // We do NOT reset paid amount. We check if new due is covered by paid. // This query updates amount_due based on percentage and new total. // Then updates status: // - If paid >= due -> PAID // - If paid > 0 but < due -> PARTIAL // - Else -> UNPAID const updateSharesQuery = ` UPDATE expense_shares SET amount_due = (percentage * ? / 100), status = CASE WHEN amount_paid >= (percentage * ? / 100) - 0.01 THEN 'PAID' WHEN amount_paid > 0 THEN 'PARTIAL' ELSE 'UNPAID' END WHERE expense_id = ? `; await connection.query(updateSharesQuery, [newTotalAmount, newTotalAmount, expenseId]); await connection.commit(); res.json({ success: true }); } catch (e) { await connection.rollback(); console.error(e); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); app.get('/api/expenses/:id/attachments/:attachmentId', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]); if (rows.length === 0) return res.status(404).json({ message: 'File not found' }); const file = rows[0]; res.json({ id: file.id, fileName: file.file_name, fileType: file.file_type, data: file.data }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => { const { amount, notes } = req.body; const expenseId = req.params.id; const userId = req.user.id; const connection = await pool.getConnection(); try { // Find user's family const [users] = await connection.query('SELECT family_id FROM users WHERE id = ?', [userId]); if (users.length === 0 || !users[0].family_id) return res.status(400).json({ message: 'User has no family assigned' }); const familyId = users[0].family_id; // Find share const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, familyId]); if (shares.length === 0) return res.status(404).json({ message: 'No share found for this expense' }); const share = shares[0]; const newPaid = parseFloat(share.amount_paid) + parseFloat(amount); const due = parseFloat(share.amount_due); let status = 'PARTIAL'; if (newPaid >= due - 0.01) status = 'PAID'; // Tolerance for float await connection.beginTransaction(); // Update Share await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, status, share.id]); // Also record in global payments for visibility in reports (optional but requested to track family payments) // We use a special month/year or notes to distinguish await connection.query( 'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, NOW(), 13, YEAR(NOW()), ?)', [uuidv4(), familyId, amount, `Spesa Straordinaria: ${notes || 'PayPal'}`] ); await connection.commit(); res.json({ success: true }); } catch (e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); // Get User's Expenses app.get('/api/my-expenses', authenticateToken, async (req, res) => { const userId = req.user.id; const { condoId } = req.query; // Optional filter if user belongs to multiple condos (unlikely in current logic but safe) try { const [users] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]); if (!users[0]?.family_id) return res.json([]); const familyId = users[0].family_id; const [rows] = await pool.query(` SELECT e.id, e.title, e.total_amount, e.start_date, e.end_date, s.amount_due, s.amount_paid, s.status, s.percentage, e.created_at FROM expense_shares s JOIN extraordinary_expenses e ON s.expense_id = e.id WHERE s.family_id = ? AND e.condo_id = ? ORDER BY e.created_at DESC `, [familyId, condoId]); // Ensure we only get expenses for the active condo context if needed res.json(rows.map(r => ({ id: r.id, title: r.title, totalAmount: parseFloat(r.total_amount), startDate: r.start_date, endDate: r.end_date, createdAt: r.created_at, myShare: { percentage: parseFloat(r.percentage), amountDue: parseFloat(r.amount_due), amountPaid: parseFloat(r.amount_paid), status: r.status } }))); } catch (e) { res.status(500).json({ error: e.message }); } }); initDb().then(() => { app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); });