diff --git a/server/server.js b/server/server.js index cb54e4f..bc0d3e0 100644 --- a/server/server.js +++ b/server/server.js @@ -17,8 +17,7 @@ app.use(cors()); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); -// ... [Existing Email Helpers omitted for brevity] ... -// (Assume getTransporter, sendEmailToUsers, sendDirectEmail are here as in previous version) +// --- 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; @@ -35,6 +34,32 @@ async function getTransporter() { }; } +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']; @@ -57,117 +82,7 @@ const requireAdmin = (req, res, next) => { } }; -// ... [Existing Routes for Auth, Profile, Settings, Condos, Families, Notices, Payments, Users, Alerts, Tickets, Extraordinary Expenses omitted] ... -// (Retaining all previous routes. Only adding new ones below) - -// --- 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); - - // Fetch Attachments light info - 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' }); - 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 }); } -}); - -// Restore previous routes... -// [INCLUDE ALL PREVIOUS ROUTES HERE TO ENSURE SERVER.JS IS COMPLETE] -// Since I must output full content, I will include the existing ones too. -// NOTE: For brevity in XML, I am assuming the previous routes are implicitly known or I re-paste them below. -// To be safe and compliant with instructions, I will reconstruct the FULL server.js content. - -// ... [RE-PASTING FULL SERVER.JS CONTENT WITH NEW ROUTES INTEGRATED] ... - +// --- AUTH & PROFILE --- app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; try { @@ -206,6 +121,7 @@ app.put('/api/profile', authenticateToken, async (req, res) => { } 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'); @@ -213,7 +129,7 @@ app.get('/api/settings', authenticateToken, async (req, res) => { 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 } + features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true, extraordinaryExpenses: true, condoFinancialsView: false } }); } else { res.status(404).json({ message: 'Settings not found' }); } } catch (e) { res.status(500).json({ error: e.message }); } @@ -262,7 +178,7 @@ app.get('/api/years', authenticateToken, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// Condos +// --- CONDOS --- app.get('/api/condos', authenticateToken, async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM condos'); @@ -293,7 +209,7 @@ app.delete('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) } catch (e) { res.status(500).json({ error: e.message }); } }); -// Families +// --- FAMILIES --- app.get('/api/families', authenticateToken, async (req, res) => { const { condoId } = req.query; try { @@ -335,7 +251,7 @@ app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res } catch (e) { res.status(500).json({ error: e.message }); } }); -// Payments +// --- PAYMENTS (INCOME) --- app.get('/api/payments', authenticateToken, async (req, res) => { const { familyId, condoId } = req.query; try { @@ -360,7 +276,7 @@ app.post('/api/payments', authenticateToken, async (req, res) => { } catch(e) { res.status(500).json({ error: e.message }); } }); -// Users +// --- USERS --- app.get('/api/users', authenticateToken, async (req, res) => { const { condoId } = req.query; try { @@ -406,7 +322,446 @@ app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) = } catch(e) { res.status(500).json({ error: e.message }); } }); -// Notices & Alerts & Tickets & Extraordinary - Assume they remain as they were in the previous file. +// --- 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 }); } +}); initDb().then(() => { app.listen(PORT, () => {