)}
- {/* USER MODAL */}
+ {/* USER MODAL (Existing) */}
{showUserModal && (
@@ -950,10 +958,10 @@ export const SettingsPage: React.FC = () => {
)}
- {/* CONDO MODAL */}
+ {/* CONDO MODAL (UPDATED) */}
{showCondoModal && (
-
+
{editingCondo ? 'Modifica Condominio' : 'Nuovo Condominio'}
+ {/* PayPal Integration Section */}
+
+
+
+ Configurazione Pagamenti
+
+
+
+
setCondoForm({...condoForm, paypalClientId: e.target.value})}
+ />
+
Necessario per abilitare i pagamenti online delle rate.
+
+
+
{/* Notes */}
-
{/* Quota */}
@@ -1005,7 +1031,7 @@ export const SettingsPage: React.FC = () => {
)}
- {/* FAMILY MODAL */}
+ {/* FAMILY MODAL (Existing) */}
{showFamilyModal && (
@@ -1068,4 +1094,4 @@ export const SettingsPage: React.FC = () => {
)}
);
-};
+};
\ No newline at end of file
diff --git a/pages/Tickets.tsx b/pages/Tickets.tsx
new file mode 100644
index 0000000..09e867a
--- /dev/null
+++ b/pages/Tickets.tsx
@@ -0,0 +1,392 @@
+
+import React, { useEffect, useState } from 'react';
+import { CondoService } from '../services/mockDb';
+import { Ticket, TicketStatus, TicketPriority, TicketCategory, TicketAttachment } from '../types';
+import { MessageSquareWarning, Plus, Search, Filter, Paperclip, X, CheckCircle2, Clock, XCircle, FileIcon, Image as ImageIcon, Film } from 'lucide-react';
+
+export const TicketsPage: React.FC = () => {
+ const user = CondoService.getCurrentUser();
+ const isAdmin = user?.role === 'admin' || user?.role === 'poweruser';
+
+ const [tickets, setTickets] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [filterStatus, setFilterStatus] = useState('ALL');
+ const [showModal, setShowModal] = useState(false);
+ const [viewTicket, setViewTicket] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ // Form State
+ const [formTitle, setFormTitle] = useState('');
+ const [formDesc, setFormDesc] = useState('');
+ const [formCategory, setFormCategory] = useState(TicketCategory.OTHER);
+ const [formPriority, setFormPriority] = useState(TicketPriority.MEDIUM);
+ const [attachments, setAttachments] = useState<{fileName: string, fileType: string, data: string}[]>([]);
+
+ // File Reading helper
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newAttachments = [];
+ for (let i = 0; i < e.target.files.length; i++) {
+ const file = e.target.files[i];
+ // Check size (e.g. 5MB limit per file for demo safety)
+ if (file.size > 5 * 1024 * 1024) {
+ alert(`Il file ${file.name} è troppo grande (max 5MB)`);
+ continue;
+ }
+
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = error => reject(error);
+ });
+
+ newAttachments.push({
+ fileName: file.name,
+ fileType: file.type,
+ data: base64
+ });
+ }
+ setAttachments([...attachments, ...newAttachments]);
+ }
+ };
+
+ const removeAttachment = (index: number) => {
+ const newAtt = [...attachments];
+ newAtt.splice(index, 1);
+ setAttachments(newAtt);
+ };
+
+ const loadTickets = async () => {
+ setLoading(true);
+ try {
+ const list = await CondoService.getTickets();
+ setTickets(list);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadTickets();
+ }, []);
+
+ const handleCreateSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ await CondoService.createTicket({
+ title: formTitle,
+ description: formDesc,
+ category: formCategory,
+ priority: formPriority,
+ attachments: attachments
+ });
+ setShowModal(false);
+ // Reset form
+ setFormTitle('');
+ setFormDesc('');
+ setAttachments([]);
+ loadTickets();
+ } catch (e) {
+ alert("Errore creazione ticket");
+ console.error(e);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleStatusUpdate = async (status: TicketStatus) => {
+ if (!viewTicket) return;
+ try {
+ await CondoService.updateTicket(viewTicket.id, {
+ status: status,
+ priority: viewTicket.priority
+ });
+ // Update local state
+ const updated = { ...viewTicket, status };
+ setViewTicket(updated);
+ setTickets(tickets.map(t => t.id === updated.id ? updated : t));
+ } catch (e) { console.error(e); }
+ };
+
+ const handleDeleteTicket = async (id: string) => {
+ if (!confirm("Eliminare definitivamente questa segnalazione?")) return;
+ try {
+ await CondoService.deleteTicket(id);
+ setTickets(tickets.filter(t => t.id !== id));
+ setViewTicket(null);
+ } catch (e) { alert("Impossibile eliminare."); }
+ };
+
+ const filteredTickets = tickets.filter(t => filterStatus === 'ALL' || t.status === filterStatus);
+
+ // Helpers for Badge Colors
+ const getStatusColor = (s: TicketStatus) => {
+ switch(s) {
+ case TicketStatus.OPEN: return 'bg-blue-100 text-blue-700';
+ case TicketStatus.IN_PROGRESS: return 'bg-yellow-100 text-yellow-700';
+ case TicketStatus.RESOLVED: return 'bg-green-100 text-green-700';
+ case TicketStatus.CLOSED: return 'bg-slate-200 text-slate-600';
+ }
+ };
+
+ const getPriorityColor = (p: TicketPriority) => {
+ switch(p) {
+ case TicketPriority.LOW: return 'bg-slate-100 text-slate-600';
+ case TicketPriority.MEDIUM: return 'bg-blue-50 text-blue-600';
+ case TicketPriority.HIGH: return 'bg-orange-100 text-orange-600';
+ case TicketPriority.URGENT: return 'bg-red-100 text-red-600';
+ }
+ };
+
+ const getCategoryLabel = (c: TicketCategory) => {
+ switch(c) {
+ case TicketCategory.MAINTENANCE: return 'Manutenzione';
+ case TicketCategory.ADMINISTRATIVE: return 'Amministrazione';
+ case TicketCategory.CLEANING: return 'Pulizie';
+ case TicketCategory.NOISE: return 'Disturbo';
+ default: return 'Altro';
+ }
+ };
+
+ const openAttachment = async (ticketId: string, attId: string) => {
+ try {
+ const file = await CondoService.getTicketAttachment(ticketId, attId);
+ // Open base64 in new tab
+ const win = window.open();
+ if (win) {
+ // Determine display method
+ if (file.fileType.startsWith('image/')) {
+ win.document.write(`
`);
+ } else if (file.fileType === 'application/pdf') {
+ win.document.write(``);
+ } else {
+ // Download link fallback
+ win.document.write(`Clicca qui per scaricare ${file.fileName}`);
+ }
+ }
+ } catch (e) { alert("Errore apertura file"); }
+ };
+
+ return (
+
+
+
+
Segnalazioni
+
Gestisci guasti e richieste
+
+
+
+
+ {/* Filters */}
+
+ {['ALL', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'].map(status => (
+
+ ))}
+
+
+ {/* List */}
+ {loading ? (
+
Caricamento...
+ ) : filteredTickets.length === 0 ? (
+
+
+
Nessuna segnalazione trovata.
+
+ ) : (
+
+ {filteredTickets.map(ticket => (
+
setViewTicket(ticket)} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow cursor-pointer relative group">
+
+
+ {ticket.status.replace('_', ' ')}
+
+
+ {ticket.priority}
+
+
+
{ticket.title}
+
{new Date(ticket.createdAt).toLocaleDateString()} • {getCategoryLabel(ticket.category)}
+
{ticket.description}
+
+
+
+
+ {ticket.userName ? ticket.userName.charAt(0) : '?'}
+
+
{ticket.userName || 'Utente'}
+
+ {ticket.attachments && ticket.attachments.length > 0 && (
+
+
{ticket.attachments.length}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* CREATE MODAL */}
+ {showModal && (
+
+
+
+
Nuova Segnalazione
+
+
+
+
+
+ )}
+
+ {/* VIEW DETAILS MODAL */}
+ {viewTicket && (
+
+
+
+
+
{viewTicket.title}
+
{new Date(viewTicket.createdAt).toLocaleString()} • {getCategoryLabel(viewTicket.category)}
+
+
+
+
+
+
+ {viewTicket.status.replace('_', ' ')}
+
+
+ {viewTicket.priority}
+
+
+
+
+ {viewTicket.description}
+
+
+ {/* Attachments */}
+ {viewTicket.attachments && viewTicket.attachments.length > 0 && (
+
+
Allegati
+
+ {viewTicket.attachments.map(att => (
+
+ ))}
+
+
+ )}
+
+ {/* Admin Actions */}
+ {isAdmin && (
+
+
Gestione Admin
+
+ {viewTicket.status !== TicketStatus.IN_PROGRESS && (
+
+ )}
+ {viewTicket.status !== TicketStatus.RESOLVED && (
+
+ )}
+ {viewTicket.status !== TicketStatus.CLOSED && (
+
+ )}
+
+
+
+ )}
+ {!isAdmin && viewTicket.status === TicketStatus.OPEN && (
+
+
+
+ )}
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/server/Dockerfile b/server/Dockerfile
index aee4f85..4f0dfdb 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,7 +1 @@
-FROM node:18-alpine
-WORKDIR /app
-COPY package*.json ./
-RUN npm install --production
-COPY . .
-EXPOSE 3001
-CMD ["node", "server.js"]
+���^
\ No newline at end of file
diff --git a/server/db.js b/server/db.js
index a7f24f7..a3664bf 100644
--- a/server/db.js
+++ b/server/db.js
@@ -60,6 +60,7 @@ const initDb = async () => {
console.log(`Database connected successfully using ${DB_CLIENT}.`);
const TIMESTAMP_TYPE = 'TIMESTAMP';
+ const LONG_TEXT_TYPE = DB_CLIENT === 'postgres' ? 'TEXT' : 'LONGTEXT'; // For base64 files
// 0. Settings Table (Global App Settings)
await connection.query(`
@@ -82,21 +83,26 @@ const initDb = async () => {
zip_code VARCHAR(20),
notes TEXT,
iban VARCHAR(50),
+ paypal_client_id VARCHAR(255),
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
image VARCHAR(255),
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP
)
`);
- // Migration for condos: Add new address fields
+ // Migration for condos: Add new address fields and paypal_client_id
try {
let hasCity = false;
+ let hasPayPal = false;
+
if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='condos'");
hasCity = cols.some(c => c.column_name === 'city');
+ hasPayPal = cols.some(c => c.column_name === 'paypal_client_id');
} else {
const [cols] = await connection.query("SHOW COLUMNS FROM condos");
hasCity = cols.some(c => c.Field === 'city');
+ hasPayPal = cols.some(c => c.Field === 'paypal_client_id');
}
if (!hasCity) {
@@ -107,6 +113,12 @@ const initDb = async () => {
await connection.query("ALTER TABLE condos ADD COLUMN zip_code VARCHAR(20)");
await connection.query("ALTER TABLE condos ADD COLUMN notes TEXT");
}
+
+ if (!hasPayPal) {
+ console.log('Migrating: Adding PayPal fields to condos...');
+ await connection.query("ALTER TABLE condos ADD COLUMN paypal_client_id VARCHAR(255)");
+ }
+
} catch(e) { console.warn("Condos migration warning:", e.message); }
@@ -254,6 +266,37 @@ const initDb = async () => {
FOREIGN KEY (notice_id) REFERENCES notices(id) ON DELETE CASCADE
)
`);
+
+ // 8. Tickets Table (Segnalazioni)
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS tickets (
+ id VARCHAR(36) PRIMARY KEY,
+ condo_id VARCHAR(36) NOT NULL,
+ user_id VARCHAR(36) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ status VARCHAR(20) DEFAULT 'OPEN',
+ priority VARCHAR(20) DEFAULT 'MEDIUM',
+ category VARCHAR(20) DEFAULT 'OTHER',
+ created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
+ updated_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ `);
+
+ // 9. Ticket Attachments Table
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS ticket_attachments (
+ id VARCHAR(36) PRIMARY KEY,
+ ticket_id VARCHAR(36) NOT NULL,
+ file_name VARCHAR(255) NOT NULL,
+ file_type VARCHAR(100),
+ data ${LONG_TEXT_TYPE}, -- Base64 encoded file
+ created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
+ )
+ `);
// --- SEEDING ---
const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
@@ -289,4 +332,4 @@ const initDb = async () => {
}
};
-module.exports = { pool: dbInterface, initDb };
+module.exports = { pool: dbInterface, initDb };
\ No newline at end of file
diff --git a/server/server.js b/server/server.js
index efcf836..7a7169a 100644
--- a/server/server.js
+++ b/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}`);
});
-});
+});
\ No newline at end of file
diff --git a/services/mockDb.ts b/services/mockDb.ts
index bd7b787..a4e468a 100644
--- a/services/mockDb.ts
+++ b/services/mockDb.ts
@@ -1,5 +1,5 @@
-import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead } from '../types';
+import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead, Ticket, TicketAttachment } from '../types';
// --- CONFIGURATION TOGGLE ---
const FORCE_LOCAL_DB = false;
@@ -288,8 +288,41 @@ export const CondoService = {
await request(`/alerts/${id}`, { method: 'DELETE' });
},
+ // --- TICKETS ---
+
+ getTickets: async (condoId?: string): Promise => {
+ let url = '/tickets';
+ const activeId = condoId || CondoService.getActiveCondoId();
+ if (activeId) url += `?condoId=${activeId}`;
+ return request(url);
+ },
+
+ createTicket: async (data: Partial & { attachments?: { fileName: string, fileType: string, data: string }[] }) => {
+ const activeId = CondoService.getActiveCondoId();
+ if(!activeId) throw new Error("No active condo");
+ return request('/tickets', {
+ method: 'POST',
+ body: JSON.stringify({ ...data, condoId: activeId })
+ });
+ },
+
+ updateTicket: async (id: string, data: { status: string, priority: string }) => {
+ return request(`/tickets/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ },
+
+ deleteTicket: async (id: string) => {
+ await request(`/tickets/${id}`, { method: 'DELETE' });
+ },
+
+ getTicketAttachment: async (ticketId: string, attachmentId: string): Promise => {
+ return request(`/tickets/${ticketId}/attachments/${attachmentId}`);
+ },
+
// --- SEEDING ---
seedPayments: () => {
// No-op in remote mode
}
-};
+};
\ No newline at end of file
diff --git a/types.ts b/types.ts
index 45f7525..885959b 100644
--- a/types.ts
+++ b/types.ts
@@ -9,6 +9,7 @@ export interface Condo {
zipCode?: string; // CAP
notes?: string; // Note
iban?: string;
+ paypalClientId?: string; // PayPal Client ID for receiving payments
defaultMonthlyQuota: number;
image?: string; // Optional placeholder for logo
}
@@ -108,3 +109,51 @@ export interface AuthResponse {
token: string;
user: User;
}
+
+// --- TICKETS ---
+
+export enum TicketStatus {
+ OPEN = 'OPEN',
+ IN_PROGRESS = 'IN_PROGRESS',
+ RESOLVED = 'RESOLVED',
+ CLOSED = 'CLOSED'
+}
+
+export enum TicketPriority {
+ LOW = 'LOW',
+ MEDIUM = 'MEDIUM',
+ HIGH = 'HIGH',
+ URGENT = 'URGENT'
+}
+
+export enum TicketCategory {
+ MAINTENANCE = 'MAINTENANCE', // Manutenzione
+ ADMINISTRATIVE = 'ADMINISTRATIVE', // Amministrativa
+ NOISE = 'NOISE', // Disturbo/Rumori
+ CLEANING = 'CLEANING', // Pulizie
+ OTHER = 'OTHER' // Altro
+}
+
+export interface TicketAttachment {
+ id: string;
+ ticketId: string;
+ fileName: string;
+ fileType: string; // MIME type
+ data: string; // Base64 Data URI
+}
+
+export interface Ticket {
+ id: string;
+ condoId: string;
+ userId: string;
+ title: string;
+ description: string;
+ status: TicketStatus;
+ priority: TicketPriority;
+ category: TicketCategory;
+ createdAt: string;
+ updatedAt: string;
+ attachments?: TicketAttachment[];
+ userName?: string; // Joined field
+ userEmail?: string; // Joined field
+}
\ No newline at end of file