import { Router } from 'express'; import multer from 'multer'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import db from '../db/index.js'; import { requireAuth, requireAdmin } from '../middleware/auth.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ── Stockage fichiers ────────────────────────────────────────────────────── const dataDir = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : path.resolve(__dirname, '../../../data'); const attachDir = path.join(dataDir, 'attachments'); fs.mkdirSync(attachDir, { recursive: true }); const storage = multer.diskStorage({ destination: (_, __, cb) => cb(null, attachDir), filename: (_, file, cb) => { const ext = path.extname(file.originalname); const base = `${Date.now()}-${Math.random().toString(36).slice(2)}`; cb(null, base + ext); }, }); const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } }); // 20 Mo max const router = Router(); router.use(requireAuth); // ── Helpers ──────────────────────────────────────────────────────────────── function generateTicketNumber() { const year = new Date().getFullYear(); const last = db.prepare( "SELECT ticket_number FROM tickets WHERE ticket_number LIKE ? ORDER BY id DESC LIMIT 1" ).get(`TK-${year}-%`); const seq = last ? parseInt(last.ticket_number.split('-')[2], 10) + 1 : 1; return `TK-${year}-${String(seq).padStart(4, '0')}`; } function notifyAdmins(title, body, link) { const admins = db.prepare("SELECT id FROM users WHERE role = 'admin'").all(); const insert = db.prepare( 'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)' ); const tx = db.transaction((items) => { for (const admin of items) insert.run(admin.id, 'ticket_reply', title, body, link); }); tx(admins); } function notifyUser(userId, title, body, link) { db.prepare( 'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)' ).run(userId, 'ticket_reply', title, body, link); } function attachmentsForMessage(messageId) { return db.prepare( 'SELECT id, filename, original_name, size, mime_type FROM ticket_attachments WHERE message_id = ?' ).all(messageId); } // ── GET /api/tickets ───────────────────────────────────────────────────── // Admin : tous les tickets. User : ses tickets uniquement. router.get('/', (req, res) => { const { id: userId, role } = req.user; const isAdmin = role === 'admin'; const rows = isAdmin ? db.prepare(` SELECT t.*, u.display_name as user_name, u.email as user_email, (SELECT COUNT(*) FROM ticket_messages WHERE ticket_id = t.id) as message_count, (SELECT body FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_body, (SELECT created_at FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_message_at FROM tickets t JOIN users u ON u.id = t.user_id ORDER BY t.updated_at DESC `).all() : db.prepare(` SELECT t.*, u.display_name as user_name, u.email as user_email, (SELECT COUNT(*) FROM ticket_messages WHERE ticket_id = t.id) as message_count, (SELECT body FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_body, (SELECT created_at FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_message_at FROM tickets t JOIN users u ON u.id = t.user_id WHERE t.user_id = ? ORDER BY t.updated_at DESC `).all(userId); res.json({ tickets: rows }); }); // ── POST /api/tickets — créer un ticket ─────────────────────────────────── router.post('/', upload.array('attachments', 10), (req, res) => { const { id: userId } = req.user; const { subject, body, ticket_type, ticket_pages } = req.body; if (!subject?.trim() || !body?.trim()) { return res.status(400).json({ error: 'Sujet et message requis' }); } const ticketNumber = generateTicketNumber(); const tx = db.transaction(() => { const ticketResult = db.prepare( 'INSERT INTO tickets (user_id, ticket_number, subject, ticket_type, ticket_pages) VALUES (?, ?, ?, ?, ?)' ).run(userId, ticketNumber, subject.trim(), ticket_type || null, ticket_pages || null); const ticketId = ticketResult.lastInsertRowid; const msgResult = db.prepare( 'INSERT INTO ticket_messages (ticket_id, user_id, body, is_admin) VALUES (?, ?, ?, 0)' ).run(ticketId, userId, body.trim()); const messageId = msgResult.lastInsertRowid; // Pièces jointes if (req.files?.length) { const insertAttach = db.prepare( 'INSERT INTO ticket_attachments (message_id, filename, original_name, size, mime_type) VALUES (?, ?, ?, ?, ?)' ); for (const f of req.files) { insertAttach.run(messageId, f.filename, f.originalname, f.size, f.mimetype); } } return { ticketId, ticketNumber }; }); const { ticketId, ticketNumber: tn } = tx(); // Notifier les admins const user = db.prepare('SELECT display_name as name FROM users WHERE id = ?').get(userId); notifyAdmins( `[${tn}] Nouveau ticket : ${subject.trim()}`, `${user?.name ?? 'Utilisateur'} a ouvert un ticket de support.`, `/communication?ticket=${ticketId}` ); res.status(201).json({ ok: true, ticketId, ticketNumber: tn }); }); // ── GET /api/tickets/:id — détail ticket + messages ─────────────────────── router.get('/:id', (req, res) => { const { id: userId, role } = req.user; const isAdmin = role === 'admin'; const ticket = db.prepare(` SELECT t.*, u.display_name as user_name, u.email as user_email, a.display_name as assigned_name FROM tickets t JOIN users u ON u.id = t.user_id LEFT JOIN users a ON a.id = t.assigned_to WHERE t.id = ? `).get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); const messages = db.prepare(` SELECT m.*, u.display_name as author_name, u.email as author_email FROM ticket_messages m JOIN users u ON u.id = m.user_id WHERE m.ticket_id = ? ORDER BY m.created_at ASC `).all(req.params.id); for (const msg of messages) { msg.attachments = attachmentsForMessage(msg.id); } res.json({ ticket, messages }); }); // ── POST /api/tickets/:id/messages — répondre à un ticket ───────────────── router.post('/:id/messages', upload.array('attachments', 10), (req, res) => { const { id: userId, role } = req.user; const isAdmin = role === 'admin'; const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); if (ticket.status === 'resolved' && !isAdmin) return res.status(400).json({ error: 'Ticket résolu' }); const { body } = req.body; if (!body?.trim()) return res.status(400).json({ error: 'Message vide' }); const tx = db.transaction(() => { const msgResult = db.prepare( 'INSERT INTO ticket_messages (ticket_id, user_id, body, is_admin) VALUES (?, ?, ?, ?)' ).run(ticket.id, userId, body.trim(), isAdmin ? 1 : 0); const messageId = msgResult.lastInsertRowid; if (req.files?.length) { const insertAttach = db.prepare( 'INSERT INTO ticket_attachments (message_id, filename, original_name, size, mime_type) VALUES (?, ?, ?, ?, ?)' ); for (const f of req.files) { insertAttach.run(messageId, f.filename, f.originalname, f.size, f.mimetype); } } // Réouverture automatique si l'émetteur répond sur un ticket en attente if (!isAdmin && ticket.status === 'pending') { db.prepare("UPDATE tickets SET status = 'open', updated_at = datetime('now') WHERE id = ?").run(ticket.id); } else { db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id); } return messageId; }); tx(); // Notifications croisées if (isAdmin) { notifyUser( ticket.user_id, `[${ticket.ticket_number}] Réponse à votre ticket`, `L'équipe support a répondu à votre ticket "${ticket.subject}".`, `/communication?ticket=${ticket.id}` ); window?.dispatchEvent; // no-op server side } else { const user = db.prepare('SELECT display_name as name FROM users WHERE id = ?').get(userId); notifyAdmins( `[${ticket.ticket_number}] Nouvelle réponse`, `${user?.name ?? 'Utilisateur'} a répondu sur le ticket "${ticket.subject}".`, `/communication?ticket=${ticket.id}` ); } res.json({ ok: true }); }); // ── PATCH /api/tickets/:id/status — résoudre / rouvrir (admin) ──────────── router.patch('/:id/status', requireAdmin, (req, res) => { const { status } = req.body; if (!['open', 'resolved', 'pending'].includes(status)) return res.status(400).json({ error: 'Statut invalide' }); const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); db.prepare("UPDATE tickets SET status = ?, updated_at = datetime('now') WHERE id = ?") .run(status, req.params.id); res.json({ ok: true }); }); // ── PATCH /api/tickets/:id/categorize — modifier type et pages ─────────────── router.patch('/:id/categorize', (req, res) => { const { id: userId, role } = req.user; const isAdmin = role === 'admin'; const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); const { ticket_type, ticket_pages } = req.body; db.prepare("UPDATE tickets SET ticket_type = ?, ticket_pages = ?, updated_at = datetime('now') WHERE id = ?") .run(ticket_type ?? null, ticket_pages ?? null, req.params.id); res.json({ ok: true }); }); // ── PATCH /api/tickets/:id/assign — assigner à un admin ────────────────────── router.patch('/:id/assign', requireAdmin, (req, res) => { const { assigned_to } = req.body; // null pour désassigner if (assigned_to !== null && assigned_to !== undefined) { const admin = db.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigned_to); if (!admin) return res.status(400).json({ error: 'Utilisateur invalide ou non admin' }); } const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); db.prepare("UPDATE tickets SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?") .run(assigned_to ?? null, req.params.id); res.json({ ok: true }); }); // ── PUT /api/tickets/:id/messages/:msgId — éditer un message (5 min) ──────── router.put('/:id/messages/:msgId', (req, res) => { const { id: userId } = req.user; const { body } = req.body; if (!body?.trim()) return res.status(400).json({ error: 'Message vide' }); const msg = db.prepare('SELECT * FROM ticket_messages WHERE id = ?').get(req.params.msgId); if (!msg) return res.status(404).json({ error: 'Message introuvable' }); // Seul l'auteur peut modifier son propre message if (msg.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); // Vérifier la fenêtre de 5 minutes depuis created_at const created = new Date(msg.created_at + 'Z'); const ageSeconds = (Date.now() - created.getTime()) / 1000; if (ageSeconds > 300) { return res.status(403).json({ error: 'Délai de modification expiré (5 minutes)' }); } db.prepare("UPDATE ticket_messages SET body = ?, updated_at = datetime('now') WHERE id = ?") .run(body.trim(), msg.id); res.json({ ok: true }); }); // ── GET /api/tickets/attachments/:filename — servir un fichier ──────────── router.get('/attachments/:filename', (req, res) => { const { id: userId, role } = req.user; const isAdmin = role === 'admin'; // Vérifier que l'utilisateur a accès à cette pièce jointe const attach = db.prepare(` SELECT ta.*, tm.ticket_id FROM ticket_attachments ta JOIN ticket_messages tm ON tm.id = ta.message_id JOIN tickets t ON t.id = tm.ticket_id WHERE ta.filename = ? AND (t.user_id = ? OR ? = 1) `).get(req.params.filename, userId, isAdmin ? 1 : 0); if (!attach) return res.status(404).json({ error: 'Fichier introuvable' }); const filePath = path.join(attachDir, req.params.filename); res.setHeader('Content-Disposition', `inline; filename="${attach.original_name}"`); res.sendFile(filePath); }); export default router;