Files
crowdlending-app/backend/src/routes/tickets.js
T
2026-06-18 22:42:10 +02:00

321 lines
13 KiB
JavaScript

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;