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:
2025-12-07 19:49:59 +01:00
parent 2566b406e1
commit 5311400615
14 changed files with 977 additions and 146 deletions

View File

@@ -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}`);
});
});
});