feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits

This commit is contained in:
dev
2026-09-16 20:09:06 +03:00
parent e8337c2845
commit 2784084c68
2 changed files with 55 additions and 0 deletions
+24
View File
@@ -219,6 +219,29 @@ function goPage(p) {
loadEntries(); loadEntries();
} }
let liveSource = null;
let liveReconnectTimer = null;
function connectLiveUpdates() {
if (liveSource) { liveSource.close(); liveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
liveSource.addEventListener('entries_changed', () => {
showToast('Поступила новая запись — список обновлён');
page = 1;
loadEntries();
});
liveSource.onerror = () => {
if (liveSource) { liveSource.close(); liveSource = null; }
if (liveReconnectTimer) clearTimeout(liveReconnectTimer);
liveReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (liveSource) liveSource.close(); });
}
async function openEdit(id) { async function openEdit(id) {
const e = currentEntries.find(x => x.id === id); const e = currentEntries.find(x => x.id === id);
if (!e) return; if (!e) return;
@@ -670,5 +693,6 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
loadGroupOptions(); loadGroupOptions();
loadStudentOptions(); loadStudentOptions();
loadEntries(); loadEntries();
connectLiveUpdates();
} }
})(); })();
+31
View File
@@ -63,6 +63,36 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:
function invalidateStudents() { cacheDrop('students:'); } function invalidateStudents() { cacheDrop('students:'); }
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); } function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); } function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
const sseClients = new Set();
function broadcastEntryChanged() {
const frame = `event: entries_changed\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`;
for (const client of sseClients) {
try { client.write(frame); } catch (e) { sseClients.delete(client); }
}
}
app.get('/api/events', async (req, res) => {
try {
const token = req.headers['x-auth-token'] || req.query.token;
const user = await loadUserByToken(token);
if (!user || !user.is_active) return res.status(401).end();
} catch (e) {
return res.status(500).end();
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.write(':ok\n\n');
sseClients.add(res);
const ping = setInterval(() => {
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); sseClients.delete(res); }
}, 25000);
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
});
function invalidateShare() { cacheDrop('share:payload:'); } function invalidateShare() { cacheDrop('share:payload:'); }
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); } function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
function invalidateAll() { cacheStore.clear(); } function invalidateAll() { cacheStore.clear(); }
@@ -3325,6 +3355,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
if (entryAutoChecker) entryAutoChecker.notify(); if (entryAutoChecker) entryAutoChecker.notify();
invalidateEntries(); invalidateEntries();
invalidateStats(); invalidateStats();
broadcastEntryChanged();
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length }); res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
} catch (e) { } catch (e) {
await client.query('ROLLBACK').catch(() => {}); await client.query('ROLLBACK').catch(() => {});