Files
Condopay/server/server.js
frakarr 76c1a097b5 feat: Add SMTP testing and improve Docker setup
Introduce a new feature to test SMTP configuration directly from the settings page. This involves adding a new API endpoint and corresponding UI elements to trigger and display the results of an SMTP test.

Additionally, this commit refactors the Docker setup by consolidating Dockerfiles and removing unnecessary configuration files. The goal is to streamline the build process and reduce image size and complexity.
2025-12-09 17:44:25 +01:00

859 lines
35 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, zip_code, 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;
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 ? (typeof r.target_families === 'string' ? JSON.parse(r.target_families) : r.target_families) : []
})));
} 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 {
const targetFamiliesJson = targetFamilyIds && targetFamilyIds.length > 0 ? JSON.stringify(targetFamilyIds) : null;
await pool.query(
'INSERT INTO notices (id, condo_id, title, content, type, link, active, target_families, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())',
[id, condoId, title, content, type, link, active, targetFamiliesJson]
);
res.json({ id, condoId, title, content, type, link, active, targetFamilyIds });
} 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 {
const targetFamiliesJson = targetFamilyIds && targetFamilyIds.length > 0 ? JSON.stringify(targetFamilyIds) : null;
await pool.query(
'UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ?, target_families = ? WHERE id = ?',
[title, content, type, link, active, targetFamiliesJson, 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.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, read_at) VALUES (?, ?, NOW())', [userId, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/:id/reads', 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.get('/api/notices/unread', authenticateToken, async (req, res) => {
const { userId, condoId } = req.query;
try {
// First get user's family ID to filter targeted notices
const [users] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]);
const userFamilyId = users.length > 0 ? users[0].family_id : null;
const [rows] = await pool.query(`
SELECT n.* FROM notices n
LEFT JOIN notice_reads nr ON n.id = nr.notice_id AND nr.user_id = ?
WHERE n.condo_id = ? AND n.active = TRUE AND nr.read_at IS NULL
ORDER BY n.date DESC
`, [userId, condoId]);
// Filter in JS for simplicity across DBs (handling JSON field logic)
const filtered = rows.filter(n => {
if (!n.target_families) return true; // Public to all
let targets = n.target_families;
if (typeof targets === 'string') {
try { targets = JSON.parse(targets); } catch(e) { return true; }
}
if (!Array.isArray(targets) || targets.length === 0) return true; // Empty array = Public
// If explicit targets are set, user MUST belong to one of the families
return userFamilyId && targets.includes(userFamilyId);
});
res.json(filtered.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
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- PAYMENTS ---
app.get('/api/payments', authenticateToken, async (req, res) => {
const { familyId, condoId } = req.query;
try {
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
if (!isPrivileged) {
if (familyId && familyId !== req.user.familyId) return res.status(403).json({ message: 'Forbidden' });
if (!familyId) {
const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]);
return res.json(rows.map(mapPaymentRow));
}
}
let query = 'SELECT p.* FROM payments p';
let params = [];
// If condoId provided, we need to JOIN with families to filter
if (condoId) {
query += ' JOIN families f ON p.family_id = f.id WHERE f.condo_id = ?';
params.push(condoId);
if (familyId) {
query += ' AND p.family_id = ?';
params.push(familyId);
}
} else if (familyId) {
query += ' WHERE p.family_id = ?';
params.push(familyId);
}
// Sort by date (newest first)
query += ' ORDER BY p.date_paid DESC';
const [rows] = await pool.query(query, params);
res.json(rows.map(mapPaymentRow));
} catch (e) { res.status(500).json({ error: e.message }); }
});
function mapPaymentRow(r) { return { 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 }; }
app.post('/api/payments', authenticateToken, async (req, res) => {
const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body;
// Security Check:
// Admin can post for anyone.
// Regular users can only post for their own family (e.g. PayPal automated callback)
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
if (!isPrivileged) {
if (familyId !== req.user.familyId) {
return res.status(403).json({message: "Forbidden: You can only record payments for your own family."});
}
}
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, new Date(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, requireAdmin, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts FROM users u';
let params = [];
// Filter users by condo.
// Logic: Users belong to families, families belong to condos.
if (condoId) {
query += ' LEFT JOIN families f ON u.family_id = f.id WHERE f.condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, email: r.email, name: r.name, role: r.role, phone: r.phone, familyId: r.family_id, receiveAlerts: !!r.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, familyId, phone, receiveAlerts } = req.body;
try {
const hashedPassword = await bcrypt.hash(password, 10);
const id = uuidv4();
await pool.query('INSERT INTO users (id, email, password_hash, name, role, family_id, phone, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, email, hashedPassword, name, role || 'user', familyId || null, phone, 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, role, familyId, name, phone, password, receiveAlerts } = req.body;
try {
let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?, receive_alerts = ?';
let params = [email, role, familyId || null, 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(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, requireAdmin, 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 });
} 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 (SEGNALAZIONI) ---
app.get('/api/tickets', authenticateToken, async (req, res) => {
const { condoId } = req.query;
const userId = req.user.id;
const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser';
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
WHERE t.condo_id = ?
`;
let params = [condoId];
// If not admin, restrict to own tickets
if (!isAdmin) {
query += ' AND t.user_id = ?';
params.push(userId);
}
query += ' ORDER BY t.created_at DESC';
const [rows] = await pool.query(query, params);
// Fetch attachments for these tickets
const ticketIds = rows.map(r => r.id);
let attachmentsMap = {};
if (ticketIds.length > 0) {
const placeholders = ticketIds.map(() => '?').join(',');
// Exclude 'data' column to keep listing light
const [attRows] = await pool.query(`SELECT id, ticket_id, file_name, file_type FROM ticket_attachments WHERE ticket_id IN (${placeholders})`, ticketIds);
attRows.forEach(a => {
if (!attachmentsMap[a.ticket_id]) attachmentsMap[a.ticket_id] = [];
attachmentsMap[a.ticket_id].push({ id: a.id, fileName: a.file_name, fileType: a.file_type });
});
}
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: attachmentsMap[r.id] || []
}));
res.json(result);
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/tickets/:id/attachments/:attachmentId', authenticateToken, async (req, res) => {
// Serve file content
try {
const [rows] = await pool.query('SELECT * FROM ticket_attachments WHERE id = ? AND ticket_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.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT c.*, u.name as user_name, u.role as user_role
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,
isAdminResponse: r.user_role === 'admin' || r.user_role === 'poweruser'
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
const { text } = req.body;
const userId = req.user.id;
const ticketId = req.params.id;
const commentId = uuidv4();
const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser';
try {
await pool.query(
'INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)',
[commentId, ticketId, userId, text]
);
// --- EMAIL NOTIFICATION LOGIC ---
// 1. Get ticket info to know who to notify
const [ticketRows] = await pool.query(`
SELECT t.title, t.user_id, u.email as creator_email, u.receive_alerts as creator_alerts
FROM tickets t
JOIN users u ON t.user_id = u.id
WHERE t.id = ?
`, [ticketId]);
if (ticketRows.length > 0) {
const ticket = ticketRows[0];
const subject = `Nuovo commento sul ticket: ${ticket.title}`;
// If ADMIN replied -> Notify Creator
if (isAdmin && ticket.creator_email && ticket.creator_alerts) {
const body = `Salve,\n\nÈ stato aggiunto un nuovo commento al tuo ticket "${ticket.title}".\n\nCommento:\n${text}\n\nAccedi alla piattaforma per rispondere.`;
sendDirectEmail(ticket.creator_email, subject, body);
}
// If CREATOR replied -> Notify Admins (logic similar to new ticket)
else if (!isAdmin) {
const [admins] = await pool.query(`
SELECT u.email FROM users u
LEFT JOIN families f ON u.family_id = f.id
JOIN tickets t ON t.id = ?
WHERE (u.role = 'admin' OR u.role = 'poweruser')
AND (f.condo_id = t.condo_id OR u.family_id IS NULL)
AND u.receive_alerts = TRUE
`, [ticketId]);
const body = `Salve,\n\nNuova risposta dall'utente sul ticket "${ticket.title}".\n\nCommento:\n${text}\n\nAccedi alla piattaforma per gestire.`;
for(const admin of admins) {
if (admin.email) sendDirectEmail(admin.email, subject, body);
}
}
}
res.json({ success: true, id: commentId });
} 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 userId = req.user.id;
const ticketId = uuidv4();
// Begin transaction
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'INSERT INTO tickets (id, condo_id, user_id, title, description, category, priority, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[ticketId, condoId, userId, title, description, category, priority || 'MEDIUM', 'OPEN']
);
if (attachments && Array.isArray(attachments)) {
for (const att of attachments) {
const attId = uuidv4();
await connection.query(
'INSERT INTO ticket_attachments (id, ticket_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)',
[attId, ticketId, att.fileName, att.fileType, att.data]
);
}
}
await connection.commit();
// --- EMAIL NOTIFICATION TO ADMINS ---
// Find Admins/PowerUsers for this condo (or global) who want alerts
const [admins] = await connection.query(`
SELECT u.email FROM users u
LEFT JOIN families f ON u.family_id = f.id
WHERE (u.role = 'admin' OR u.role = 'poweruser')
AND (f.condo_id = ? OR u.family_id IS NULL)
AND u.receive_alerts = TRUE
`, [condoId]);
const adminEmails = admins.map(a => a.email).filter(e => e);
if (adminEmails.length > 0) {
// Fetch user name for clearer email
const [uRows] = await connection.query('SELECT name FROM users WHERE id = ?', [userId]);
const userName = uRows[0]?.name || 'Un condomino';
const subject = `Nuova Segnalazione: ${title}`;
const body = `Salve,\n\n${userName} ha aperto una nuova segnalazione.\n\nOggetto: ${title}\nCategoria: ${category}\nPriorità: ${priority || 'MEDIUM'}\n\nDescrizione:\n${description}\n\nAccedi alla piattaforma per gestire il ticket.`;
// Loop to send individually or use BCC
for(const email of adminEmails) {
sendDirectEmail(email, subject, body);
}
}
res.json({ success: true, id: ticketId });
} 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;
const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser';
// Only admins/powerusers can change status/priority for now
if (!isAdmin) return res.status(403).json({ message: 'Forbidden' });
try {
await pool.query(
'UPDATE tickets SET status = ?, priority = ? WHERE id = ?',
[status, priority, req.params.id]
);
// --- EMAIL NOTIFICATION TO USER ---
const [tRows] = await pool.query('SELECT t.title, t.user_id, u.email, u.receive_alerts FROM tickets t JOIN users u ON t.user_id = u.id WHERE t.id = ?', [req.params.id]);
if (tRows.length > 0) {
const ticket = tRows[0];
if (ticket.email && ticket.receive_alerts) {
const subject = `Aggiornamento Ticket: ${ticket.title}`;
const body = `Salve,\n\nIl tuo ticket "${ticket.title}" è stato aggiornato.\n\nNuovo Stato: ${status}\nPriorità: ${priority}\n\nAccedi alla piattaforma per i dettagli.`;
sendDirectEmail(ticket.email, subject, body);
}
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/tickets/:id', authenticateToken, async (req, res) => {
// Only delete own ticket if open, or admin can delete any
// MODIFIED: Prevent deletion if status is CLOSED or RESOLVED (Archived)
const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser';
const userId = req.user.id;
try {
// Check status first
const [rows] = await pool.query('SELECT status, user_id FROM tickets WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ message: 'Ticket not found' });
const ticket = rows[0];
// Block deletion of Archived tickets
if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') {
return res.status(403).json({ message: 'Cannot delete archived tickets. They are kept for history.' });
}
let query = 'DELETE FROM tickets WHERE id = ?';
let params = [req.params.id];
if (!isAdmin) {
// Additional check for user ownership
if (ticket.user_id !== userId) return res.status(403).json({ message: 'Forbidden' });
if (ticket.status !== 'OPEN') return res.status(403).json({ message: 'Can only delete OPEN tickets' });
}
await pool.query(query, params);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
initDb().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});