From 2784084c68defa62ac863cee16588b97d25334db Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 16 Sep 2026 20:09:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(journal):=20live=20updates=20on=20new=20en?= =?UTF-8?q?try=20via=20SSE=20(/api/events)=20=E2=80=94=20admin=20journal?= =?UTF-8?q?=20auto-refreshes=20when=20a=20student=20submits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/journal.js | 24 ++++++++++++++++++++++++ server.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/public/js/journal.js b/public/js/journal.js index 866c90e..56999e3 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -219,6 +219,29 @@ function goPage(p) { 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) { const e = currentEntries.find(x => x.id === id); if (!e) return; @@ -670,5 +693,6 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink); loadGroupOptions(); loadStudentOptions(); loadEntries(); + connectLiveUpdates(); } })(); \ No newline at end of file diff --git a/server.js b/server.js index b80ec23..04563f9 100644 --- a/server.js +++ b/server.js @@ -63,6 +63,36 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload: function invalidateStudents() { cacheDrop('students:'); } function invalidateGroups() { cacheDrop('groups:'); 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 invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); } function invalidateAll() { cacheStore.clear(); } @@ -3325,6 +3355,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => { if (entryAutoChecker) entryAutoChecker.notify(); invalidateEntries(); invalidateStats(); + broadcastEntryChanged(); res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length }); } catch (e) { await client.query('ROLLBACK').catch(() => {});