Files
Condopay/server/server.js
frakarr 8a43143ead feat(expenses): Add delete expense endpoint and functionality
Implements the ability to delete an expense, including its associated items and shares. Also refactors the expense update logic to correctly handle share updates and adds the corresponding API endpoint and mock DB function.
2025-12-09 23:25:06 +01:00

1067 lines
42 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());
// 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 ---
app.get('/api/notices', authenticateToken, async (req, res) => {
const { condoId } = req.query; // Usually filtered by condo
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,
targetFamilyIds: 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));
const [u] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]);
const familyId = u[0]?.family_id;
const unread = notices.filter(n => {
if (readIds.has(n.id)) return false;
// Target Check
if (n.target_families && n.target_families.length > 0) {
if (!familyId) return false;
// n.target_families is parsed by mysql2 driver if column type is JSON
// However, pg might need manual parsing if not automatic.
// Let's assume it's array.
const targets = (typeof n.target_families === 'string') ? JSON.parse(n.target_families) : n.target_families;
return Array.isArray(targets) && targets.includes(familyId);
}
return true; // Public
});
res.json(unread.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,
targetFamilyIds: r.target_families
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/:id/read-status', 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.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 }); }
});
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)]
);
res.json({ success: true });
} 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 }); }
});
// --- PAYMENTS ---
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;
// Allow users to pay for themselves or admin to pay for anyone
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 provided, we filter users belonging to families in that condo
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 }); }
});
// --- ALERTS ---
app.get('/api/alerts', authenticateToken, 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, 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 user_name, u.email as user_email
FROM tickets t
JOIN users u ON t.user_id = u.id
`;
let params = [];
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
query += ' WHERE t.user_id = ?';
params.push(req.user.id);
} else if (condoId) {
query += ' WHERE t.condo_id = ?';
params.push(condoId);
}
query += ' ORDER BY t.created_at DESC';
const [rows] = await pool.query(query, params);
const ticketIds = rows.map(r => r.id);
let attachments = [];
if (ticketIds.length > 0) {
const [atts] = await pool.query(`SELECT id, ticket_id FROM ticket_attachments`);
attachments = atts;
}
const result = 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.user_name,
userEmail: r.user_email,
attachments: attachments.filter(a => a.ticket_id === r.id)
}));
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/tickets', authenticateToken, async (req, res) => {
const { condoId, title, description, category, priority, 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, category, priority) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, condoId, req.user.id, title, description, category, priority]
);
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 });
} 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;
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 {
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/comments', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT c.*, u.name as user_name
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.user_name,
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]
);
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: '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 }); }
});
// --- 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, shares } = 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 (Complex Logic: Sync requested shares with DB)
// If shares are provided in the update:
if (shares && shares.length > 0) {
// Get current DB shares to delete removed ones
const [currentDbShares] = await connection.query('SELECT family_id FROM expense_shares WHERE expense_id = ?', [expenseId]);
const currentFamilyIds = currentDbShares.map(s => s.family_id);
const newFamilyIds = shares.map(s => s.familyId);
// A. Delete shares for families removed from list
const toDelete = currentFamilyIds.filter(fid => !newFamilyIds.includes(fid));
if (toDelete.length > 0) {
// Construct placeholder string (?,?,?)
const placeholders = toDelete.map(() => '?').join(',');
await connection.query(`DELETE FROM expense_shares WHERE expense_id = ? AND family_id IN (${placeholders})`, [expenseId, ...toDelete]);
}
// B. Upsert (Update existing or Insert new)
// We use ON DUPLICATE KEY UPDATE logic manually or loop since we need to respect 'amount_paid'
for (const share of shares) {
// Check if exists
const [existing] = await connection.query('SELECT amount_paid FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, share.familyId]);
if (existing.length > 0) {
// Update
const currentPaid = parseFloat(existing[0].amount_paid);
let newStatus = 'UNPAID';
if (currentPaid >= share.amountDue - 0.01) newStatus = 'PAID';
else if (currentPaid > 0) newStatus = 'PARTIAL';
await connection.query(
'UPDATE expense_shares SET percentage = ?, amount_due = ?, status = ? WHERE expense_id = ? AND family_id = ?',
[share.percentage, share.amountDue, newStatus, expenseId, share.familyId]
);
} else {
// Insert
await connection.query(
'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, 0, ?)',
[uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID']
);
}
}
} else {
// If no shares provided (empty list), maybe we should clear all?
// Or maybe it means "don't touch shares".
// Based on frontend logic, empty list means "remove all assignments".
// But usually we don't send empty list if we just edited header.
// Assuming the frontend sends the full current state of shares.
// If explicit empty array is sent, we delete all.
// If undefined/null, we do nothing (backward compatibility).
if (Array.isArray(shares)) {
await connection.query('DELETE FROM expense_shares WHERE expense_id = ?', [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();
}
});
// Delete Expense
app.delete('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
// Foreign keys set to ON DELETE CASCADE should handle children (items, shares, attachments)
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
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}`);
});
});