Files
Condopay/server/server.js
2025-12-10 23:06:47 +01:00

1074 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;
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) => {
let { condoId, title, description, category, priority, attachments } = req.body;
const id = uuidv4();
const connection = await pool.getConnection();
try {
// Robustness: Resolve condoId if missing
if (!condoId) {
// 1. Try from User's Family
if (req.user.familyId) {
const [f] = await connection.query('SELECT condo_id FROM families WHERE id = ?', [req.user.familyId]);
if (f.length > 0) condoId = f[0].condo_id;
}
// 2. Fallback to first condo
if (!condoId) {
const [c] = await connection.query('SELECT id FROM condos LIMIT 1');
if (c.length > 0) condoId = c[0].id;
}
}
if (!condoId) {
return res.status(400).json({ message: "Impossibile determinare il condominio. Selezionane uno o contatta l'amministratore." });
}
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 explicit empty array is sent, we delete all.
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]);
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;
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 = ?
ORDER BY e.created_at DESC
`, [familyId]);
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}`);
});
});