feat: Add tickets module and PayPal integration
Introduces a new 'Tickets' module for users to submit and manage issues within their condominium. This includes defining ticket types, statuses, priorities, and categories. Additionally, this commit integrates PayPal as a payment option for family fee payments, enabling users to pay directly via PayPal using their client ID. Key changes: - Added `Ticket` related types and enums. - Implemented `TicketService` functions for CRUD operations. - Integrated `@paypal/react-paypal-js` library. - Added `paypalClientId` to `AppSettings` and `Condo` types. - Updated `FamilyDetail` page to include PayPal payment option. - Added 'Segnalazioni' navigation link to `Layout`.
This commit is contained in:
283
server/server.js
283
server/server.js
@@ -13,30 +13,41 @@ const PORT = process.env.PORT || 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
// Increased limit to support base64 file uploads for tickets
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// --- EMAIL & SCHEDULER (Same as before) ---
|
||||
async function sendEmailToUsers(subject, body) {
|
||||
try {
|
||||
// --- 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;
|
||||
if (!settings.length || !settings[0].smtp_config) return null;
|
||||
|
||||
const config = settings[0].smtp_config;
|
||||
if (!config.host || !config.user || !config.pass) return;
|
||||
if (!config.host || !config.user || !config.pass) return null;
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: { user: config.user, pass: config.pass },
|
||||
});
|
||||
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 transporter.sendMail({
|
||||
from: config.fromEmail || config.user,
|
||||
await setup.transporter.sendMail({
|
||||
from: setup.from,
|
||||
bcc: bccList,
|
||||
subject: subject,
|
||||
text: body,
|
||||
@@ -44,6 +55,21 @@ async function sendEmailToUsers(subject, 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'];
|
||||
@@ -152,29 +178,30 @@ app.get('/api/condos', authenticateToken, async (req, res) => {
|
||||
province: r.province,
|
||||
zipCode: r.zip_code,
|
||||
notes: r.notes,
|
||||
iban: r.iban,
|
||||
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 } = req.body;
|
||||
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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota]
|
||||
'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 });
|
||||
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 } = req.body;
|
||||
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 = ? WHERE id = ?',
|
||||
[name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, req.params.id]
|
||||
'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 }); }
|
||||
@@ -213,9 +240,9 @@ app.get('/api/families', authenticateToken, async (req, res) => {
|
||||
condoId: r.condo_id,
|
||||
name: r.name,
|
||||
unitNumber: r.unit_number,
|
||||
stair: r.stair,
|
||||
floor: r.floor,
|
||||
notes: r.notes,
|
||||
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
|
||||
@@ -335,9 +362,20 @@ app.get('/api/payments', authenticateToken, async (req, res) => {
|
||||
} 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;
|
||||
if (req.user.role !== 'admin') return res.status(403).json({message: "Only admins can record payments"});
|
||||
|
||||
// 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]);
|
||||
@@ -431,8 +469,199 @@ app.delete('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res)
|
||||
} 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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user