This commit is contained in:
2026-06-18 22:11:30 +02:00
parent b2e73b4fe7
commit 0f3c27050b
4 changed files with 301 additions and 59 deletions
+136
View File
@@ -0,0 +1,136 @@
import db from '../db/index.js';
const JOB_NAME = 'auto_ticket_status';
function writeLog({ status, nbChanges, details, errorMsg }) {
try {
db.prepare(`
INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg)
VALUES (?, ?, ?, ?, ?)
`).run(JOB_NAME, status, nbChanges ?? 0, details ?? null, errorMsg ?? null);
} catch (e) {
console.error('[autoTicketStatus] Impossible d\'écrire dans job_logs :', e.message);
}
}
/**
* Règles appliquées :
*
* 1. open → pending
* Ticket ouvert sans aucun message depuis plus de 2 jours.
* (updated_at sert d'horodatage de la dernière activité)
*
* 2. pending → resolved (clôture automatique)
* Ticket en attente où le créateur du ticket n'a pas posté de message
* depuis plus de 5 jours.
* On cherche le dernier message de l'émetteur (is_admin = 0 et user_id = ticket.user_id).
* Si ce message date de plus de 5 jours, le ticket est clôturé.
*/
export function checkTicketStatuses() {
let nbChanges = 0;
const details = [];
// ── Règle 1 : open → pending (pas de réponse depuis 2 jours) ─────────────
const toPending = db.prepare(`
SELECT id, ticket_number, subject, updated_at
FROM tickets
WHERE status = 'open'
AND datetime(updated_at) < datetime('now', '-2 days')
`).all();
const setPending = db.prepare(`
UPDATE tickets SET status = 'pending', updated_at = datetime('now') WHERE id = ?
`);
const notifyUserPending = db.prepare(`
INSERT INTO notifications (user_id, type, title, body, link)
SELECT user_id, 'ticket_reply',
'[' || ticket_number || '] Ticket mis en attente',
'Votre ticket "' || subject || '" est passé en attente faute de réponse depuis 2 jours.',
'/communication?ticket=' || id
FROM tickets WHERE id = ?
`);
for (const t of toPending) {
setPending.run(t.id);
try { notifyUserPending.run(t.id); } catch (_) { /* best-effort */ }
details.push(`[pending] ${t.ticket_number} (id=${t.id})`);
nbChanges++;
}
// ── Règle 2 : pending → resolved (pas de réponse créateur depuis 5 jours) ─
const pendingTickets = db.prepare(`
SELECT id, ticket_number, subject, user_id
FROM tickets
WHERE status = 'pending'
`).all();
const lastCreatorMsg = db.prepare(`
SELECT MAX(created_at) as last_at
FROM ticket_messages
WHERE ticket_id = ? AND user_id = ? AND is_admin = 0
`);
const setResolved = db.prepare(`
UPDATE tickets SET status = 'resolved', updated_at = datetime('now') WHERE id = ?
`);
const notifyUserResolved = db.prepare(`
INSERT INTO notifications (user_id, type, title, body, link)
SELECT user_id, 'ticket_reply',
'[' || ticket_number || '] Ticket clôturé automatiquement',
'Votre ticket "' || subject || '" a été clôturé automatiquement après 5 jours sans réponse.',
'/communication?ticket=' || id
FROM tickets WHERE id = ?
`);
for (const t of pendingTickets) {
const row = lastCreatorMsg.get(t.id, t.user_id);
// Si jamais de message créateur, on prend la date de création du ticket (updated_at)
const refDate = row?.last_at ?? null;
if (!refDate) continue; // pas de message créateur trouvé, on ne clôture pas
const ageDays = (Date.now() - new Date(refDate + 'Z').getTime()) / (1000 * 60 * 60 * 24);
if (ageDays > 5) {
setResolved.run(t.id);
try { notifyUserResolved.run(t.id); } catch (_) { /* best-effort */ }
details.push(`[resolved] ${t.ticket_number} (id=${t.id}, last_creator_msg=${refDate})`);
nbChanges++;
}
}
const summary = nbChanges === 0
? 'Aucun ticket modifié'
: details.join('; ');
writeLog({ status: 'ok', nbChanges, details: summary });
if (nbChanges > 0) {
console.log(`[autoTicketStatus] ${nbChanges} ticket(s) mis à jour : ${summary}`);
}
return nbChanges;
}
/**
* Démarre le job — exécution immédiate au démarrage, puis toutes les heures.
*/
export function startAutoTicketStatusJob() {
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
try {
checkTicketStatuses();
} catch (err) {
console.error('[autoTicketStatus] Erreur initiale :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
setInterval(() => {
try {
checkTicketStatuses();
} catch (err) {
console.error('[autoTicketStatus] Erreur :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
}, INTERVAL_MS);
console.log('[autoTicketStatus] Job démarré — vérification toutes les heures');
}
+2
View File
@@ -33,6 +33,7 @@ import { errorHandler } from './middleware/errorHandler.js';
import { requireAuth, requireAdmin } from './middleware/auth.js'; import { requireAuth, requireAdmin } from './middleware/auth.js';
import { startAutoStatutJob } from './jobs/autoStatut.js'; import { startAutoStatutJob } from './jobs/autoStatut.js';
import { startAutoExportJob } from './jobs/autoExport.js'; import { startAutoExportJob } from './jobs/autoExport.js';
import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js';
import adminRouter from './routes/admin.js'; import adminRouter from './routes/admin.js';
import invitationsRouter from './routes/invitations.js'; import invitationsRouter from './routes/invitations.js';
import auditLogsRouter from './routes/auditLogs.js'; import auditLogsRouter from './routes/auditLogs.js';
@@ -141,4 +142,5 @@ app.listen(PORT, () => {
console.log(`Crowdlending API listening on http://localhost:${PORT}`); console.log(`Crowdlending API listening on http://localhost:${PORT}`);
startAutoStatutJob(); startAutoStatutJob();
startAutoExportJob(); startAutoExportJob();
startAutoTicketStatusJob();
}); });
+153 -50
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx'; import { useAuth } from '../context/AuthContext.jsx';
@@ -189,13 +190,29 @@ const TYPE_COLORS = {
// ── TagSelect ───────────────────────────────────────────────────────────── // ── TagSelect ─────────────────────────────────────────────────────────────
function TagSelect({ label, options, value, onChange, multi = false }) { function TagSelect({ label, options, value, onChange, multi = false }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const triggerRef = useRef(null);
const dropRef = useRef(null);
useEffect(() => { useEffect(() => {
const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; if (!open) return;
const h = e => {
if (
triggerRef.current && !triggerRef.current.contains(e.target) &&
dropRef.current && !dropRef.current.contains(e.target)
) setOpen(false);
};
document.addEventListener('mousedown', h); document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []); }, [open]);
const openDropdown = () => {
if (!open && triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect();
setDropPos({ top: r.bottom + 4, left: r.left, width: r.width });
}
setOpen(o => !o);
};
const isSelected = v => multi ? (value || []).includes(v) : value === v; const isSelected = v => multi ? (value || []).includes(v) : value === v;
@@ -215,33 +232,61 @@ function TagSelect({ label, options, value, onChange, multi = false }) {
: (value ? options.find(o => o.value === value)?.label ?? label : label); : (value ? options.find(o => o.value === value)?.label ?? label : label);
return ( return (
<div className="tag-select" ref={ref}> <div className="tag-select" ref={triggerRef}>
<button <button
type="button" type="button"
className={`tag-select-trigger${selectedCount > 0 ? ' has-value' : ''}${open ? ' open' : ''}`} className={`tag-select-trigger${selectedCount > 0 ? ' has-value' : ''}${open ? ' open' : ''}`}
onClick={() => setOpen(o => !o)} onClick={openDropdown}
> >
<span>{headerLabel}</span> <span>{headerLabel}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
{open ? <polyline points="18 15 12 9 6 15"/> : <polyline points="6 9 12 15 18 9"/>} {open ? <polyline points="18 15 12 9 6 15"/> : <polyline points="6 9 12 15 18 9"/>}
</svg> </svg>
</button> </button>
{open && ( {open && createPortal(
<div className="tag-select-dropdown"> <div
{options.map(opt => ( ref={dropRef}
<label key={opt.value} className={`tag-select-option${isSelected(opt.value) ? ' selected' : ''}`}> className="tag-select-dropdown"
<input style={{
type={multi ? 'checkbox' : 'radio'} position: 'fixed',
checked={isSelected(opt.value)} top: dropPos.top,
onChange={() => toggle(opt.value)} left: dropPos.left,
/> minWidth: dropPos.width,
<span>{opt.label}</span> zIndex: 9999,
{isSelected(opt.value) && ( }}
<svg className="tag-select-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg> >
{options.map(opt => {
const sel = isSelected(opt.value);
return (
<div
key={opt.value}
className={`tag-select-option${sel ? ' selected' : ''}`}
onClick={() => toggle(opt.value)}
>
{/* Bulle radio/checkbox custom */}
<span style={{
flexShrink: 0,
width: 14, height: 14,
borderRadius: multi ? 3 : '50%',
border: `2px solid ${sel ? 'var(--primary)' : 'var(--border)'}`,
background: sel ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{sel && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.5">
{multi
? <polyline points="20 6 9 17 4 12"/>
: <circle cx="12" cy="12" r="4" fill="#fff" stroke="none"/>
}
</svg>
)} )}
</label> </span>
))} <span>{opt.label}</span>
</div> </div>
);
})}
</div>,
document.body
)} )}
</div> </div>
); );
@@ -530,7 +575,7 @@ export default function Communication() {
const [tickets, setTickets] = useState([]); const [tickets, setTickets] = useState([]);
const [ticketSearch, setTicketSearch] = useState(''); const [ticketSearch, setTicketSearch] = useState('');
const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread' const [notifFilter, setNotifFilter] = useState(new Set(['read', 'unread'])); // multi-sélection
const [selectedTicket, setSelectedTicket] = useState(null); const [selectedTicket, setSelectedTicket] = useState(null);
const [thread, setThread] = useState(null); // { ticket, messages } const [thread, setThread] = useState(null); // { ticket, messages }
const [notifs, setNotifs] = useState([]); const [notifs, setNotifs] = useState([]);
@@ -822,18 +867,6 @@ export default function Communication() {
{/* Colonne 2 — Dossier + filtres */} {/* Colonne 2 — Dossier + filtres */}
<div className="comm-topbar-2"> <div className="comm-topbar-2">
<span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span> <span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span>
{tab === 'notifications' && (
<button
className="btn-icon-sm"
style={{ marginLeft: 'auto', flexShrink: 0 }}
onClick={e => {
const r = e.currentTarget.getBoundingClientRect();
setNotifMenuPos({ x: r.right, y: r.bottom + 4 });
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
)}
</div> </div>
{/* Colonne 3 — Toolbar contextuelle */} {/* Colonne 3 — Toolbar contextuelle */}
<div className="comm-topbar-3"> <div className="comm-topbar-3">
@@ -853,13 +886,17 @@ export default function Communication() {
</div> </div>
</> </>
)} )}
{tab === 'notifications' && selectedNotif && !selectedNotif.read && ( {tab === 'notifications' && selectedNotif && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}> <div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<button className="btn btn-sm btn-ghost" onClick={async () => { <button
await api.patch(`/notifications/${selectedNotif.id}/read`); className="btn-icon-sm"
setNotifs(prev => prev.map(n => n.id === selectedNotif.id ? { ...n, read: 1 } : n)); onClick={e => {
setSelectedNotif(prev => ({ ...prev, read: 1 })); const r = e.currentTarget.getBoundingClientRect();
}}>Marquer lu</button> setNotifMenuPos({ x: r.right, y: r.bottom + 4 });
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div> </div>
)} )}
</div> </div>
@@ -931,9 +968,33 @@ export default function Communication() {
<> <>
<div className="comm-list-header" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}> <div className="comm-list-header" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
<div className="comm-topbar-filters"> <div className="comm-topbar-filters">
{[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => ( {[['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => {
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button> const active = ticketFilter.has(v);
))} return (
<button
key={v}
className="comm-topbar-filter-btn"
style={active ? {
background: 'var(--primary)',
color: '#fff',
borderColor: 'var(--primary)',
display: 'flex', alignItems: 'center', gap: 5,
} : {}}
onClick={() => setTicketFilter(prev => {
const next = new Set(prev);
if (next.has(v)) { next.delete(v); } else { next.add(v); }
return next;
})}
>
{active && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
{l}
</button>
);
})}
</div> </div>
<input <input
className="comm-search" className="comm-search"
@@ -952,7 +1013,7 @@ export default function Communication() {
</div> </div>
)} )}
{tickets {tickets
.filter(t => ticketFilter === 'all' || t.status === ticketFilter) .filter(t => ticketFilter.size === 0 || ticketFilter.has(t.status))
.filter(t => { .filter(t => {
if (!ticketSearch.trim()) return true; if (!ticketSearch.trim()) return true;
const q = ticketSearch.toLowerCase(); const q = ticketSearch.toLowerCase();
@@ -992,7 +1053,9 @@ export default function Communication() {
{tab === 'notifications' && (() => { {tab === 'notifications' && (() => {
const NOTIF_PER_PAGE = 10; const NOTIF_PER_PAGE = 10;
const filteredNotifs = notifFilter === 'unread' ? notifs.filter(n => !n.read) : notifs; const filteredNotifs = notifFilter.size === 2 || notifFilter.size === 0
? notifs
: notifFilter.has('unread') ? notifs.filter(n => !n.read) : notifs.filter(n => n.read);
const totalNotifs = filteredNotifs.length; const totalNotifs = filteredNotifs.length;
const lastPage = Math.max(0, Math.ceil(totalNotifs / NOTIF_PER_PAGE) - 1); const lastPage = Math.max(0, Math.ceil(totalNotifs / NOTIF_PER_PAGE) - 1);
const pagedNotifs = filteredNotifs.slice(notifPage * NOTIF_PER_PAGE, (notifPage + 1) * NOTIF_PER_PAGE); const pagedNotifs = filteredNotifs.slice(notifPage * NOTIF_PER_PAGE, (notifPage + 1) * NOTIF_PER_PAGE);
@@ -1002,12 +1065,36 @@ export default function Communication() {
<> <>
<div className="comm-list-header"> <div className="comm-list-header">
<div className="comm-topbar-filters"> <div className="comm-topbar-filters">
{[['all','Tous'],['unread','Non lus']].map(([v,l]) => ( {[['unread','Non lues'],['read','Lues']].map(([v,l]) => {
<button key={v} className={`comm-topbar-filter-btn${notifFilter === v ? ' active' : ''}`} onClick={() => { setNotifFilter(v); setNotifPage(0); }}>{l}</button> const active = notifFilter.has(v);
))} return (
{unreadCount > 0 && ( <button
<button className="comm-topbar-action-btn" style={{ marginLeft: 'auto' }} onClick={markAllRead}>Tout marquer lu</button> key={v}
className="comm-topbar-filter-btn"
style={active ? {
background: 'var(--primary)',
color: '#fff',
borderColor: 'var(--primary)',
display: 'flex', alignItems: 'center', gap: 5,
} : {}}
onClick={() => {
setNotifFilter(prev => {
const next = new Set(prev);
if (next.has(v)) { next.delete(v); } else { next.add(v); }
return next;
});
setNotifPage(0);
}}
>
{active && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)} )}
{l}
</button>
);
})}
</div> </div>
</div> </div>
<div className="comm-list-scroll"> <div className="comm-list-scroll">
@@ -1392,6 +1479,22 @@ export default function Communication() {
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 180, padding: '4px 0', minWidth: 180,
}}> }}>
{selectedNotif && !selectedNotif.read && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={async () => {
await api.patch(`/notifications/${selectedNotif.id}/read`);
setNotifs(prev => prev.map(n => n.id === selectedNotif.id ? { ...n, read: 1 } : n));
setSelectedNotif(prev => ({ ...prev, read: 1 }));
setNotifMenuPos(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Marquer comme lu
</button>
)}
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
@@ -1400,7 +1503,7 @@ export default function Communication() {
onClick={() => { markAllRead(); setNotifMenuPos(null); }} onClick={() => { markAllRead(); setNotifMenuPos(null); }}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Tout marquer lu Tout considérer comme lu
</button> </button>
)} )}
<button <button
+7 -6
View File
@@ -3183,10 +3183,6 @@ tr:hover td { background: var(--surface-2); }
.tag-select-trigger.has-value { color: var(--text); } .tag-select-trigger.has-value { color: var(--text); }
.tag-select-trigger.open { border-color: var(--primary); } .tag-select-trigger.open { border-color: var(--primary); }
.tag-select-dropdown { .tag-select-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
right: 0;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -3199,12 +3195,17 @@ tr:hover td { background: var(--surface-2); }
.tag-select-option { .tag-select-option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding: 7px 10px; padding: 3px 10px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: var(--text); color: var(--text);
white-space: nowrap;
text-align: left;
justify-content: flex-start;
width: 100%;
box-sizing: border-box;
} }
.tag-select-option:hover { background: var(--surface-2); } .tag-select-option:hover { background: var(--surface-2); }
.tag-select-option.selected { color: var(--primary); font-weight: 600; } .tag-select-option.selected { color: var(--primary); font-weight: 600; }