Centre de notification
This commit is contained in:
@@ -1898,4 +1898,109 @@ console.log('[DB] Migrations 2FA OK');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table notifications ───────────────────────────────────────────────────
|
||||
{
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'").get();
|
||||
if (!tables) {
|
||||
db.exec(`
|
||||
CREATE TABLE notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'system',
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
link TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_user_id ON notifications(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(user_id, read)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(created_at)');
|
||||
console.log('[DB] Table notifications créée');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tickets support ───────────────────────────────────────────────────────────
|
||||
{
|
||||
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
|
||||
if (!cols.includes('id')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticket_number TEXT NOT NULL UNIQUE,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_updated ON tickets(updated_at)');
|
||||
console.log('[DB] Table tickets créée');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name);
|
||||
if (!cols.includes('id')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_ticket_id ON ticket_messages(ticket_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_created ON ticket_messages(created_at)');
|
||||
console.log('[DB] Table ticket_messages créée');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const cols = db.prepare("PRAGMA table_info(ticket_attachments)").all().map(c => c.name);
|
||||
if (!cols.includes('id')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES ticket_messages(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mime_type TEXT
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tattach_message_id ON ticket_attachments(message_id)');
|
||||
console.log('[DB] Table ticket_attachments créée');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
// Migration : colonne updated_at sur ticket_messages (édition 5 min)
|
||||
const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name);
|
||||
if (!cols.includes('updated_at')) {
|
||||
db.exec("ALTER TABLE ticket_messages ADD COLUMN updated_at TEXT");
|
||||
console.log('[DB] ticket_messages: colonne updated_at ajoutée');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Migration : ticket_type et ticket_pages sur tickets (qualification)
|
||||
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
|
||||
if (!cols.includes('ticket_type')) {
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN ticket_type TEXT");
|
||||
console.log('[DB] tickets: colonne ticket_type ajoutée');
|
||||
}
|
||||
if (!cols.includes('ticket_pages')) {
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN ticket_pages TEXT");
|
||||
console.log('[DB] tickets: colonne ticket_pages ajoutée');
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── POST /api/notifications/seed (admin only) ─────────────────────────────────
|
||||
const SEED_NOTIFS = [
|
||||
{ type: 'system', title: 'Mise à jour système', body: "Une nouvelle version de l'application est disponible." },
|
||||
{ type: 'info', title: 'Conseil du jour', body: 'Diversifiez vos plateformes pour réduire le risque.' },
|
||||
{ type: 'success', title: 'Investissement remboursé', body: 'Le prêt #1042 a été remboursé intégralement.' },
|
||||
{ type: 'warning', title: 'Retard de paiement détecté', body: 'La plateforme Lendosphere signale un retard sur le prêt #0987.' },
|
||||
{ type: 'ticket_reply', title: 'Réponse à votre ticket', body: "Votre demande #42 a reçu une réponse de l'équipe support." },
|
||||
{ type: 'team', title: 'Nouvel investisseur', body: 'Un nouveau profil investisseur a été ajouté à votre espace.' },
|
||||
{ type: 'security', title: 'Connexion depuis un nouvel appareil', body: "Une connexion a été détectée depuis un navigateur inconnu. Si ce n'est pas vous, changez votre mot de passe." },
|
||||
{ type: 'announcement', title: 'Nouvelle fonctionnalité disponible', body: 'La page Fiscalité a été enrichie avec le simulateur CERFA 2561. Découvrez les nouveautés.' },
|
||||
];
|
||||
|
||||
router.post('/seed', requireAdmin, (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { count = 3 } = req.body ?? {};
|
||||
const n = Math.min(Math.max(1, Number(count)), SEED_NOTIFS.length);
|
||||
const shuffled = [...SEED_NOTIFS].sort(() => Math.random() - .5).slice(0, n);
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO notifications (user_id, type, title, body) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((items) => {
|
||||
for (const item of items) insert.run(userId, item.type, item.title, item.body);
|
||||
});
|
||||
insertMany(shuffled);
|
||||
res.json({ ok: true, created: n });
|
||||
});
|
||||
|
||||
// ── POST /api/notifications/broadcast (admin only) ────────────────────────────
|
||||
// Envoyer une notification manuelle à tous les users ou un user spécifique
|
||||
router.post('/broadcast', requireAdmin, (req, res) => {
|
||||
const { type = 'announcement', title, body, link, user_id } = req.body ?? {};
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titre requis' });
|
||||
|
||||
const VALID_TYPES = ['system','ticket_reply','info','team','success','warning','security','announcement'];
|
||||
if (!VALID_TYPES.includes(type)) return res.status(400).json({ error: 'Type invalide' });
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
let targets;
|
||||
if (user_id) {
|
||||
const u = db.prepare('SELECT id FROM users WHERE id = ?').get(user_id);
|
||||
if (!u) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
targets = [u];
|
||||
} else {
|
||||
targets = db.prepare('SELECT id FROM users').all();
|
||||
}
|
||||
|
||||
const tx = db.transaction((users) => {
|
||||
for (const u of users) insert.run(u.id, type, title.trim(), body?.trim() ?? null, link ?? null);
|
||||
});
|
||||
tx(targets);
|
||||
|
||||
res.json({ ok: true, sent: targets.length });
|
||||
});
|
||||
|
||||
// ── GET /api/notifications ───────────────────────────────────────────────────
|
||||
// Query params: ?unread_only=true&limit=50&offset=0&type=system
|
||||
router.get('/', (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { unread_only, type, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
let where = 'WHERE n.user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (unread_only === 'true') {
|
||||
where += ' AND n.read = 0';
|
||||
}
|
||||
if (type) {
|
||||
where += ' AND n.type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM notifications n
|
||||
${where}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, Number(limit), Number(offset));
|
||||
|
||||
const total = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM notifications n ${where}
|
||||
`).get(...params).cnt;
|
||||
|
||||
res.json({ notifications: rows, total });
|
||||
});
|
||||
|
||||
// ── GET /api/notifications/count ─────────────────────────────────────────────
|
||||
router.get('/count', (req, res) => {
|
||||
const { count } = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0'
|
||||
).get(req.user.id);
|
||||
res.json({ unread: count });
|
||||
});
|
||||
|
||||
// ── PATCH /api/notifications/read-all ────────────────────────────────────────
|
||||
router.patch('/read-all', (req, res) => {
|
||||
db.prepare('UPDATE notifications SET read = 1 WHERE user_id = ? AND read = 0')
|
||||
.run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PATCH /api/notifications/:id/read ────────────────────────────────────────
|
||||
router.patch('/:id/read', (req, res) => {
|
||||
const notif = db.prepare('SELECT id FROM notifications WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!notif) return res.status(404).json({ error: 'Notification introuvable' });
|
||||
|
||||
db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── DELETE /api/notifications/:id ────────────────────────────────────────────
|
||||
router.delete('/:id', (req, res) => {
|
||||
const notif = db.prepare('SELECT id FROM notifications WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!notif) return res.status(404).json({ error: 'Notification introuvable' });
|
||||
|
||||
db.prepare('DELETE FROM notifications WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── DELETE /api/notifications (tout supprimer) ────────────────────────────────
|
||||
router.delete('/', (req, res) => {
|
||||
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,281 @@
|
||||
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.*,
|
||||
(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
|
||||
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
|
||||
FROM tickets t JOIN users u ON u.id = t.user_id
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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'].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 });
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
@@ -46,6 +46,8 @@ import refSecteursRouter from './routes/ref-secteurs.js';
|
||||
import categoriesInvRouter from './routes/categories-inv.js';
|
||||
import secteursInvRouter from './routes/secteurs-inv.js';
|
||||
import associationsInvRouter from './routes/associations-inv.js';
|
||||
import notificationsRouter from './routes/notifications.js';
|
||||
import ticketsRouter from './routes/tickets.js';
|
||||
import db from './db/index.js';
|
||||
import { getSmtpConfig } from './utils/mailer.js';
|
||||
|
||||
@@ -130,6 +132,8 @@ app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
|
||||
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
|
||||
app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
|
||||
app.use('/api', requireAuth, associationsInvRouter);
|
||||
app.use('/api/notifications', notificationsRouter);
|
||||
app.use('/api/tickets', requireAuth, ticketsRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user