This commit refactors the application settings to include a new `AppFeatures` interface. This allows for granular control over which features are enabled for the application. The `AppFeatures` object includes boolean flags for: - `multiCondo`: Enables or disables the multi-condominium management feature. - `tickets`: Placeholder for future ticket system integration. - `payPal`: Enables or disables PayPal payment gateway integration. - `notices`: Enables or disables the display and management of notices. These flags are now fetched and stored in the application state, influencing UI elements and logic across various pages to conditionally render features based on their enabled status. For example, the multi-condo selection in `Layout.tsx` and the notice display in `FamilyList.tsx` are now gated by these feature flags. The `FamilyDetail.tsx` page also uses the `payPal` flag to conditionally enable the PayPal payment option. The `SettingsPage.tsx` has been updated to include a new 'features' tab for managing these flags.
675 lines
28 KiB
JavaScript
675 lines
28 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) => {
|
|
if (req.user && req.user.role === 'admin') {
|
|
next();
|
|
} else {
|
|
console.warn(`Access denied for user ${req.user?.email} with role ${req.user?.role}`);
|
|
res.status(403).json({ message: 'Access denied: Admins 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 }
|
|
});
|
|
} 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 }); }
|
|
});
|
|
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;
|
|
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 })));
|
|
} 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 } = req.body;
|
|
const id = uuidv4();
|
|
try {
|
|
await pool.query('INSERT INTO notices (id, condo_id, title, content, type, link, active, date) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())', [id, condoId, title, content, type, link, active]);
|
|
res.json({ id, condoId, title, content, type, link, active });
|
|
} 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 } = req.body;
|
|
try {
|
|
await pool.query('UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ? WHERE id = ?', [title, content, type, link, active, 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 {
|
|
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]);
|
|
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 })));
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// --- PAYMENTS ---
|
|
app.get('/api/payments', authenticateToken, async (req, res) => {
|
|
const { familyId } = 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 * FROM payments';
|
|
let params = [];
|
|
if (familyId) {
|
|
query += ' WHERE family_id = ?';
|
|
params.push(familyId);
|
|
}
|
|
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.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
|
|
const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser';
|
|
const userId = req.user.id;
|
|
|
|
try {
|
|
let query = 'DELETE FROM tickets WHERE id = ?';
|
|
let params = [req.params.id];
|
|
|
|
if (!isAdmin) {
|
|
query += ' AND user_id = ? AND status = "OPEN"'; // Users can only delete their own OPEN tickets
|
|
params.push(userId);
|
|
}
|
|
|
|
const [result] = await pool.query(query, params);
|
|
if (result.affectedRows === 0) {
|
|
return res.status(403).json({ message: 'Cannot delete ticket (Permission denied or not found)' });
|
|
}
|
|
|
|
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}`);
|
|
});
|
|
});
|