/** * Public (no-auth) guest classroom routes. * Guests access the whiteboard read-only via a token in the URL. * * GET /api/classroom/guest/:token — session info (pre-join) * POST /api/classroom/guest/:token/join — choose display name, get guestId * GET /api/classroom/guest/:token/strokes — strokes for ?page_num=N * GET /api/classroom/guest/:token/stream — SSE stream (?guestId=X) * POST /api/classroom/guest/:token/leave — notify departure (optional) */ const router = require('express').Router(); const crypto = require('crypto'); const db = require('../db/db'); const { addGuestClient, removeGuestClient, emitToGuests, emit } = require('../sse'); /* ── In-memory guest registry (cleared on server restart — fine for guests) */ const guests = new Map(); // guestId → { name, sessionId, connectedAt } /* Helper: look up session by token (active only) */ function sessionByToken(token) { return db.prepare( "SELECT id, title, status, current_page, teacher_id FROM classroom_sessions WHERE guest_token=?" ).get(token); } /* Helper: emit guest-joined/left to the real participants of the session */ function notifySession(sessionId, data) { const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId); if (!session) return; const { emit: _emit, emitToClass } = require('../sse'); if (session.class_id) { emitToClass(session.class_id, data); _emit(session.teacher_id, data); } else { _emit(session.teacher_id, data); const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId); for (const { user_id } of invites) _emit(user_id, data); } } /* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */ router.get('/:token', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); const pageCount = db.prepare( 'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?' ).get(session.id).c || 1; res.json({ id: session.id, title: session.title || 'Онлайн-урок', status: session.status, current_page: session.current_page || 1, page_count: pageCount, }); }); /* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */ router.post('/:token/join', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); if (session.status !== 'active') return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' }); const rawName = (req.body?.name || '').trim().slice(0, 40).replace(/[<>"&]/g, ''); const name = rawName || 'Гость'; const guestId = 'g_' + crypto.randomBytes(12).toString('base64url'); guests.set(guestId, { name, sessionId: session.id, connectedAt: Date.now() }); // Notify real participants that a guest joined notifySession(session.id, { type: 'classroom_guest_joined', sessionId: session.id, guestId, guestName: name, }); const pageCount = db.prepare( 'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?' ).get(session.id).c || 1; res.json({ guestId, sessionId: session.id, title: session.title || 'Онлайн-урок', current_page: session.current_page || 1, page_count: pageCount, }); }); /* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */ router.get('/:token/strokes', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); if (session.status !== 'active') return res.status(403).json({ error: 'Урок не активен' }); const pageNum = Math.max(1, Number(req.query.page_num) || 1); const sinceSeq = Number(req.query.since_seq) || 0; const strokes = sinceSeq > 0 ? db.prepare( 'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq' ).all(session.id, pageNum, sinceSeq) : db.prepare( 'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq' ).all(session.id, pageNum); const pageRow = db.prepare( 'SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?' ).get(session.id, pageNum); const parsed = strokes.map(s => ({ ...s, data: JSON.parse(s.data || '{}') })); res.json({ strokes: parsed, template: pageRow?.template || 'blank', seq: parsed.at(-1)?.seq || 0 }); }); /* ── GET /api/classroom/guest/:token/stream ─── SSE */ router.get('/:token/stream', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).end(); if (session.status !== 'active') return res.status(403).end(); const guestId = req.query.guestId; const guest = guestId ? guests.get(guestId) : null; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable buffering res.flushHeaders(); res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`); addGuestClient(session.id, res); req.on('close', () => { removeGuestClient(session.id, res); if (guest) { guests.delete(guestId); notifySession(session.id, { type: 'classroom_guest_left', sessionId: session.id, guestId, guestName: guest.name, }); } }); }); /* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */ router.post('/:token/leave', (req, res) => { const guestId = req.body?.guestId; const guest = guestId ? guests.get(guestId) : null; if (guest) { guests.delete(guestId); notifySession(guest.sessionId, { type: 'classroom_guest_left', sessionId: guest.sessionId, guestId, guestName: guest.name, }); } res.json({ ok: true }); }); module.exports = router;