Centre de notification

This commit is contained in:
2026-06-18 20:26:23 +02:00
parent bfc97442d9
commit 5bbbbfa956
13 changed files with 3685 additions and 0 deletions
+105
View File
@@ -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;
+137
View File
@@ -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;
+281
View File
@@ -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;
+4
View File
@@ -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);