321 lines
13 KiB
JavaScript
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;
|