feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -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(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user