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(condoId, subject, text) { const transport = await getTransporter(); if (!transport) return; // Get users with alerts enabled for this condo (linked via family) const query = ` SELECT u.email FROM users u JOIN families f ON u.family_id = f.id WHERE f.condo_id = ? AND u.receive_alerts = TRUE `; const [users] = await pool.query(query, [condoId]); const bcc = users.map(u => u.email); if (bcc.length === 0) return; try { await transport.transporter.sendMail({ from: transport.from, bcc: bcc, subject: subject, text: text }); console.log(`Email sent to ${bcc.length} users.`); } catch (e) { console.error("Email error:", e); } } // --- 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) => { if (err) return res.sendStatus(401); req.user = user; next(); }); }; const requireAdmin = (req, res, next) => { if (req.user && (req.user.role === 'admin' || req.user.role === 'poweruser')) { next(); } else { res.status(403).json({ message: 'Access denied: Privileged users only' }); } }; // --- AUTH & PROFILE --- 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 }); } }); 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 || {}, storageConfig: rows[0].storage_config || { provider: 'local_db' }, features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true, extraordinaryExpenses: true, condoFinancialsView: false, documents: 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, storageConfig } = req.body; try { await pool.query( 'UPDATE settings SET current_year = ?, smtp_config = ?, features = ?, storage_config = ? WHERE id = 1', [currentYear, JSON.stringify(smtpConfig), JSON.stringify(features), JSON.stringify(storageConfig)] ); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/settings/smtp-test', authenticateToken, requireAdmin, async (req, res) => { const config = req.body; 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 }, }); await transporter.verify(); 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) { 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, defaultMonthlyQuota: parseFloat(r.default_monthly_quota), image: r.image, dueDay: r.due_day || 10 }))); } 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, dueDay } = 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, due_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay || 10]); res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay: dueDay || 10 }); } 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, dueDay } = req.body; try { await pool.query('UPDATE condos SET name = ?, address = ?, street_number = ?, city = ?, province = ?, zip_code = ?, notes = ?, default_monthly_quota = ?, paypal_client_id = ?, due_day = ? WHERE id = ?', [name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay || 10, 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 = []; if (req.user.role !== 'admin' && req.user.role !== 'poweruser') { if (!req.user.familyId) return res.json([]); query += ' WHERE f.id = ?'; params.push(req.user.familyId); } else 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 }); } }); // --- PAYMENTS (INCOME) --- app.get('/api/payments', authenticateToken, async (req, res) => { const { familyId, condoId } = req.query; try { let query = 'SELECT p.* FROM payments p JOIN families f ON p.family_id = f.id'; let params = []; let conditions = []; if (familyId) { conditions.push('p.family_id = ?'); params.push(familyId); } if (condoId) { conditions.push('f.condo_id = ?'); params.push(condoId); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } query += ' ORDER BY p.date_paid DESC'; const [rows] = await pool.query(query, params); res.json(rows.map(r => ({ 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 }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/payments', authenticateToken, async (req, res) => { const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body; if (req.user.role !== 'admin' && req.user.role !== 'poweruser' && req.user.familyId !== familyId) return res.status(403).json({ message: 'Unauthorized' }); 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, 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, async (req, res) => { const { condoId } = req.query; try { let query = 'SELECT id, email, name, role, phone, family_id, receive_alerts, created_at FROM users'; let params = []; if (condoId) { query = `SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts, u.created_at FROM users u LEFT JOIN families f ON u.family_id = f.id WHERE f.condo_id = ? OR u.role IN ('admin', 'poweruser')`; params = [condoId]; } const [rows] = await pool.query(query, params); res.json(rows.map(u => ({ id: u.id, email: u.email, name: u.name, role: u.role, phone: u.phone, familyId: u.family_id, receiveAlerts: !!u.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, phone, familyId, receiveAlerts } = req.body; const id = uuidv4(); try { const hashedPassword = await bcrypt.hash(password || 'password', 10); await pool.query('INSERT INTO users (id, email, password_hash, name, role, phone, family_id, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, email, hashedPassword, name, role, phone, familyId || null, 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, name, role, phone, familyId, receiveAlerts, password } = req.body; try { let query = 'UPDATE users SET email = ?, name = ?, role = ?, phone = ?, family_id = ?, receive_alerts = ?'; let params = [email, name, role, phone, familyId || null, receiveAlerts]; if (password) { 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 }); } }); // --- NOTICES (BACHECA) --- app.get('/api/notices', authenticateToken, async (req, res) => { const { condoId } = req.query; try { const [rows] = await pool.query('SELECT * FROM notices WHERE condo_id = ? ORDER BY date DESC', [condoId]); res.json(rows.map(r => ({...r, targetFamilyIds: r.target_families ? r.target_families : []}))); } 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 [notices] = await pool.query( 'SELECT * FROM notices WHERE condo_id = ? AND active = TRUE ORDER BY date DESC', [condoId] ); const [reads] = await pool.query('SELECT notice_id FROM notice_reads WHERE user_id = ?', [userId]); const readIds = new Set(reads.map(r => r.notice_id)); // Filter logic: Standard user only sees Public or Targeted const user = (await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]))[0][0]; const relevant = notices.filter(n => { const targets = n.target_families || []; if (targets.length === 0) return true; // Public return user && user.family_id && targets.includes(user.family_id); }); const unread = relevant.filter(n => !readIds.has(n.id)); res.json(unread); } 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, targetFamilyIds } = req.body; const id = uuidv4(); try { await pool.query('INSERT INTO notices (id, condo_id, title, content, type, link, active, target_families) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, title, content, type, link, active, JSON.stringify(targetFamilyIds)]); if (active) sendEmailToUsers(condoId, `Nuovo Avviso: ${title}`, content); res.json({ success: true, id }); } 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, targetFamilyIds } = req.body; try { await pool.query('UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ?, target_families = ? WHERE id = ?', [title, content, type, link, active, JSON.stringify(targetFamilyIds), 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.get('/api/notices/:id/read-status', authenticateToken, requireAdmin, async (req, res) => { try { const [rows] = await pool.query('SELECT user_id, read_at FROM notice_reads WHERE notice_id = ?', [req.params.id]); res.json(rows.map(r => ({ userId: r.user_id, readAt: r.read_at }))); } 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) VALUES (?, ?)', [userId, req.params.id]); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- ALERTS --- app.get('/api/alerts', authenticateToken, async (req, res) => { const { condoId } = req.query; try { const [rows] = await pool.query('SELECT * FROM alerts WHERE condo_id = ?', [condoId]); 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 { 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, 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, id: req.params.id }); } 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 }); } }); // --- TICKETS --- app.get('/api/tickets', authenticateToken, async (req, res) => { const { condoId } = req.query; try { let query = ` SELECT t.*, u.name as userName, u.email as userEmail FROM tickets t JOIN users u ON t.user_id = u.id WHERE t.condo_id = ? `; let params = [condoId]; if (req.user.role !== 'admin' && req.user.role !== 'poweruser') { query += ' AND t.user_id = ?'; params.push(req.user.id); } query += ' ORDER BY t.updated_at DESC'; const [rows] = await pool.query(query, params); // Fetch light attachment info (no data) const [attRows] = await pool.query('SELECT id, ticket_id, file_name, file_type FROM ticket_attachments'); const results = rows.map(r => ({ id: r.id, condoId: r.condo_id, userId: r.user_id, title: r.title, description: r.description, status: r.status, priority: r.priority, category: r.category, createdAt: r.created_at, updatedAt: r.updated_at, userName: r.userName, userEmail: r.userEmail, attachments: attRows.filter(a => a.ticket_id === r.id).map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type })) })); res.json(results); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/tickets', authenticateToken, async (req, res) => { const { condoId, title, description, priority, category, attachments } = req.body; const id = uuidv4(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.query( 'INSERT INTO tickets (id, condo_id, user_id, title, description, priority, category) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, condoId, req.user.id, title, description, priority, category] ); if (attachments && attachments.length > 0) { for(const att of attachments) { await connection.query( 'INSERT INTO ticket_attachments (id, ticket_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', [uuidv4(), id, att.fileName, att.fileType, att.data] ); } } await connection.commit(); res.json({ success: true, id }); } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); app.put('/api/tickets/:id', authenticateToken, async (req, res) => { const { status, priority } = req.body; // User can only close, admin can change status/priority // In real app check permissions more granually try { await pool.query('UPDATE tickets SET status = ?, priority = ? WHERE id = ?', [status, priority, req.params.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/tickets/:id', authenticateToken, async (req, res) => { try { // Only admin or owner can delete. Simplified here. await pool.query('DELETE FROM tickets WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/tickets/:id/attachments/:attId', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM ticket_attachments WHERE id = ?', [req.params.attId]); if (rows.length === 0) return res.status(404).json({ message: '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.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => { try { const [rows] = await pool.query(` SELECT c.*, u.name as userName FROM ticket_comments c JOIN users u ON c.user_id = u.id WHERE c.ticket_id = ? ORDER BY c.created_at ASC `, [req.params.id]); res.json(rows.map(r => ({ id: r.id, ticketId: r.ticket_id, userId: r.user_id, userName: r.userName, text: r.text, createdAt: r.created_at }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => { const { text } = req.body; const id = uuidv4(); try { await pool.query('INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)', [id, req.params.id, req.user.id, text]); // Update ticket updated_at await pool.query('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); // --- EXTRAORDINARY EXPENSES --- app.get('/api/expenses', authenticateToken, async (req, res) => { const { condoId } = req.query; try { const [rows] = await pool.query('SELECT * FROM extraordinary_expenses WHERE condo_id = ? ORDER BY created_at DESC', [condoId]); res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, description: r.description, startDate: r.start_date, endDate: r.end_date, contractorName: r.contractor_name, totalAmount: parseFloat(r.total_amount) }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/expenses/:id', authenticateToken, async (req, res) => { try { const [exp] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]); if(exp.length === 0) return res.status(404).json({ message: 'Not Found' }); const [items] = await pool.query('SELECT * FROM expense_items WHERE expense_id = ?', [req.params.id]); const [shares] = await pool.query(` SELECT s.*, f.name as familyName FROM expense_shares s JOIN families f ON s.family_id = f.id WHERE s.expense_id = ? `, [req.params.id]); const [atts] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [req.params.id]); const result = { ...exp[0], totalAmount: parseFloat(exp[0].total_amount), items: items.map(i => ({ description: i.description, amount: parseFloat(i.amount) })), shares: shares.map(s => ({ id: s.id, familyId: s.family_id, familyName: s.familyName, percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status })), attachments: atts.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type })) }; // Fix keys result.startDate = result.start_date; result.endDate = result.end_date; result.contractorName = result.contractor_name; res.json(result); } 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 id = uuidv4(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const totalAmount = items.reduce((acc, i) => acc + i.amount, 0); await connection.query( 'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, title, description, startDate, endDate, contractorName, totalAmount] ); for (const item of items) { await connection.query('INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), id, item.description, item.amount]); } for (const share of shares) { await connection.query('INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, ?, ?)', [uuidv4(), id, share.familyId, share.percentage, share.amountDue, 0, 'UNPAID']); } 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(), id, att.fileName, att.fileType, att.data]); } } await connection.commit(); res.json({ success: true, id }); } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); app.delete('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => { try { await pool.query('DELETE FROM extraordinary_expenses WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/expenses/:id/attachments/:attId', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ?', [req.params.attId]); if (rows.length === 0) return res.status(404).json({ message: 'File not found' }); res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/my-expenses', authenticateToken, async (req, res) => { const { condoId } = req.query; if (!req.user.familyId) return res.json([]); try { const [shares] = await pool.query(` SELECT s.*, e.title, e.start_date, e.total_amount, e.contractor_name 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 `, [req.user.familyId, condoId]); res.json(shares.map(s => ({ id: s.expense_id, // Use expense ID as main ID for listing title: s.title, startDate: s.start_date, totalAmount: parseFloat(s.total_amount), contractorName: s.contractor_name, myShare: { percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status } }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => { const { amount, notes, familyId } = req.body; // If Admin, familyId is passed. If User, use req.user.familyId const targetFamilyId = (req.user.role === 'admin' || req.user.role === 'poweruser') ? familyId : req.user.familyId; if (!targetFamilyId) return res.status(400).json({ message: 'Family not found' }); const connection = await pool.getConnection(); try { await connection.beginTransaction(); // 1. Record payment in main payments table (linked to extraordinary expense?) // For simplicity in this schema we might just update the share or add a row in `payments` with special flag // Current Schema `payments` has `extraordinary_expense_id` column. await connection.query( 'INSERT INTO payments (id, family_id, extraordinary_expense_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [uuidv4(), targetFamilyId, req.params.id, amount, new Date(), 13, new Date().getFullYear(), notes || 'Extraordinary Payment'] ); // 2. Update Share const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [req.params.id, targetFamilyId]); if (shares.length === 0) throw new Error('Share not found'); const share = shares[0]; const newPaid = parseFloat(share.amount_paid) + parseFloat(amount); const due = parseFloat(share.amount_due); let newStatus = 'PARTIAL'; if (newPaid >= due - 0.01) newStatus = 'PAID'; // Tolerance await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]); await connection.commit(); res.json({ success: true }); } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); app.get('/api/expenses/:id/shares/:familyId/payments', authenticateToken, async (req, res) => { try { const [rows] = await pool.query( 'SELECT * FROM payments WHERE extraordinary_expense_id = ? AND family_id = ? ORDER BY date_paid DESC', [req.params.id, req.params.familyId] ); res.json(rows.map(r => ({ id: r.id, amount: parseFloat(r.amount), datePaid: r.date_paid, notes: r.notes }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/expenses/payments/:paymentId', authenticateToken, requireAdmin, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const [pay] = await connection.query('SELECT * FROM payments WHERE id = ?', [req.params.paymentId]); if (pay.length === 0) throw new Error('Payment not found'); const payment = pay[0]; if (!payment.extraordinary_expense_id) throw new Error('Not an extraordinary payment'); // Delete payment await connection.query('DELETE FROM payments WHERE id = ?', [req.params.paymentId]); // Revert share const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [payment.extraordinary_expense_id, payment.family_id]); if (shares.length > 0) { const share = shares[0]; const newPaid = Math.max(0, parseFloat(share.amount_paid) - parseFloat(payment.amount)); const newStatus = newPaid >= parseFloat(share.amount_due) - 0.01 ? 'PAID' : (newPaid > 0 ? 'PARTIAL' : 'UNPAID'); await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]); } await connection.commit(); res.json({ success: true }); } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); // --- CONDO ORDINARY EXPENSES (USCITE) --- app.get('/api/condo-expenses', authenticateToken, async (req, res) => { const { condoId, year } = req.query; try { let query = 'SELECT * FROM condo_expenses WHERE condo_id = ?'; let params = [condoId]; if (year) { query += ' AND (YEAR(created_at) = ? OR YEAR(payment_date) = ?)'; params.push(year, year); } query += ' ORDER BY created_at DESC'; const [rows] = await pool.query(query, params); const [allAtts] = await pool.query('SELECT id, condo_expense_id, file_name, file_type FROM condo_expense_attachments'); const results = rows.map(r => ({ id: r.id, condoId: r.condo_id, description: r.description, supplierName: r.supplier_name, amount: parseFloat(r.amount), paymentDate: r.payment_date, status: r.status, paymentMethod: r.payment_method, invoiceNumber: r.invoice_number, notes: r.notes, createdAt: r.created_at, attachments: allAtts.filter(a => a.condo_expense_id === r.id).map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type })) })); res.json(results); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/condo-expenses', authenticateToken, requireAdmin, async (req, res) => { const { condoId, description, supplierName, amount, paymentDate, status, paymentMethod, invoiceNumber, notes, attachments } = req.body; const id = uuidv4(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.query( 'INSERT INTO condo_expenses (id, condo_id, description, supplier_name, amount, payment_date, status, payment_method, invoice_number, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, description, supplierName, amount, paymentDate || null, status, paymentMethod, invoiceNumber, notes] ); if (attachments && attachments.length > 0) { for(const att of attachments) { await connection.query('INSERT INTO condo_expense_attachments (id, condo_expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', [uuidv4(), id, att.fileName, att.fileType, att.data]); } } await connection.commit(); res.json({ success: true, id }); } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } }); app.put('/api/condo-expenses/:id', authenticateToken, requireAdmin, async (req, res) => { const { description, supplierName, amount, paymentDate, status, paymentMethod, invoiceNumber, notes } = req.body; try { await pool.query( 'UPDATE condo_expenses SET description=?, supplier_name=?, amount=?, payment_date=?, status=?, payment_method=?, invoice_number=?, notes=? WHERE id=?', [description, supplierName, amount, paymentDate || null, status, paymentMethod, invoiceNumber, notes, req.params.id] ); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/condo-expenses/:id', authenticateToken, requireAdmin, async (req, res) => { try { await pool.query('DELETE FROM condo_expenses WHERE id = ?', [req.params.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/condo-expenses/:id/attachments/:attId', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM condo_expense_attachments WHERE id = ?', [req.params.attId]); if (rows.length === 0) return res.status(404).json({ message: 'File not found' }); res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data }); } catch(e) { res.status(500).json({ error: e.message }); } }); // --- DOCUMENTS (CLOUD/LOCAL) --- app.get('/api/documents', authenticateToken, async (req, res) => { const { condoId } = req.query; try { // We only fetch metadata, not file_data const [rows] = await pool.query('SELECT id, condo_id, title, description, file_name, file_type, file_size, tags, storage_provider, upload_date FROM documents WHERE condo_id = ? ORDER BY upload_date DESC', [condoId]); res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, description: r.description, fileName: r.file_name, fileType: r.file_type, fileSize: r.file_size, tags: r.tags || [], storageProvider: r.storage_provider, uploadDate: r.upload_date }))); } catch(e) { res.status(500).json({ error: e.message }); } }); app.post('/api/documents', authenticateToken, requireAdmin, async (req, res) => { const { condoId, title, description, fileName, fileType, fileSize, tags, fileData, storageConfig } = req.body; const id = uuidv4(); try { // Here we would implement real Cloud Storage logic based on storageConfig.provider // For 'local_db' or fallback, we save base64 in DB. let provider = storageConfig?.provider || 'local_db'; // Mocking Cloud upload by just saving to DB for demo purposes, // but acknowledging the config await pool.query( 'INSERT INTO documents (id, condo_id, title, description, file_name, file_type, file_size, tags, storage_provider, file_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, title, description, fileName, fileType, fileSize, JSON.stringify(tags), provider, fileData] ); res.json({ success: true, id }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.get('/api/documents/:id/download', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT file_name, file_type, file_data, storage_provider FROM documents WHERE id = ?', [req.params.id]); if (rows.length === 0) return res.status(404).json({ message: 'Not Found' }); const doc = rows[0]; // If external provider (S3/Drive), we would generate a Signed URL here or proxy the stream. // For local_db: res.json({ fileName: doc.file_name, fileType: doc.file_type, data: doc.file_data // Base64 }); } catch(e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/documents/:id', authenticateToken, requireAdmin, async (req, res) => { try { // Also delete from cloud if configured... await pool.query('DELETE FROM documents 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}`); }); });