diff --git a/VERSION b/VERSION index 5c259b0..07990fe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.56-beta +0.4.57-beta diff --git a/db/022_live_learning_sessions.sql b/db/022_live_learning_sessions.sql new file mode 100644 index 0000000..990a193 --- /dev/null +++ b/db/022_live_learning_sessions.sql @@ -0,0 +1,25 @@ +-- Realtime leerlingactiviteit: een sessie start pas bij echte interactie met +-- een toegewezen widget. Heartbeats houden de sessie live; afgesloten en +-- weggevallen sessies blijven kort terug te zien in het klasdashboard. +CREATE TABLE IF NOT EXISTS live_learning_sessions ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + assignment_id BIGINT REFERENCES assignments(id) ON DELETE SET NULL, + board_id TEXT NOT NULL, + widget_id TEXT NOT NULL, + widget_type TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'werken' CHECK (mode IN ('werken', 'kijken')), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + progress_count INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + correct INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_class_recent + ON live_learning_sessions (class_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_pupil_open + ON live_learning_sessions (pupil_id, ended_at, last_seen_at DESC); diff --git a/deploy/nginx.conf b/deploy/nginx.conf index ad17e58..df3e0c8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -22,6 +22,18 @@ server { gzip_min_length 1024; gzip_types text/css application/javascript application/json image/svg+xml text/plain; + location /api/progress/live/ { + proxy_pass http://app:3000; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $teach_forwarded_proto; + } + location / { proxy_pass http://app:3000; proxy_http_version 1.1; diff --git a/public/css/teach.css b/public/css/teach.css index 9e2c9ea..afc1ef1 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -2455,6 +2455,26 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 @media (max-width:600px){#settingsModal .dashboard-summary{grid-template-columns:1fr 1fr}.cast-path-step{grid-template-columns:26px minmax(0,1fr) auto auto auto}} /* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */ +/* Realtime leersessies bovenin het klasdashboard */ +#settingsModal .live-session-overview{margin-bottom:14px;padding:12px;border:1px solid color-mix(in srgb,var(--blue) 32%,var(--line));border-radius:14px;background:color-mix(in srgb,var(--blue) 5%,var(--surface))} +#settingsModal .live-session-head,#settingsModal .live-session-group-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +#settingsModal .live-session-head{margin-bottom:10px;font-size:14px} +#settingsModal .live-connection{padding:3px 8px;border-radius:99px;background:var(--surface-2);color:var(--orange);font-size:10px;font-weight:900} +#settingsModal .live-connection.connected{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)} +#settingsModal .live-session-group+.live-session-group{margin-top:12px} +#settingsModal .live-session-group-head{margin-bottom:6px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.35px} +#settingsModal .live-session-group-head span{min-width:22px;padding:2px 6px;border-radius:99px;background:var(--surface-2);text-align:center;font-weight:900} +#settingsModal .live-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:7px} +#settingsModal .live-session-card{display:flex;min-width:0;flex-direction:column;align-items:flex-start;gap:4px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer} +#settingsModal .live-session-card:hover,#settingsModal .live-session-card:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft);box-shadow:0 4px 12px rgba(30,50,90,.08)} +#settingsModal .live-session-card.live{border-color:color-mix(in srgb,var(--green) 45%,var(--line));box-shadow:inset 3px 0 var(--green)} +#settingsModal .live-session-card.recent{box-shadow:inset 3px 0 var(--line)} +#settingsModal .live-session-status{padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900} +#settingsModal .live-session-card.live .live-session-status{background:color-mix(in srgb,var(--green) 15%,var(--surface));color:var(--green)} +#settingsModal .live-session-name{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px} +#settingsModal .live-session-widget,#settingsModal .live-session-time,#settingsModal .live-session-score{max-width:100%;color:var(--muted);font-size:11px;line-height:1.35} +#settingsModal .live-session-score{font-weight:850;color:var(--accent-ink)} + #settingsModal .progress-level-badge{ display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px; border-radius:999px; background:var(--accent-soft); color:var(--accent-ink); diff --git a/public/index.html b/public/index.html index 4306562..1924afe 100644 --- a/public/index.html +++ b/public/index.html @@ -50,6 +50,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 8b4c60e..ef5d92d 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -25,6 +25,8 @@ over her-renders heen zoals adminTab hierboven. */ let kmClass = null, kmSubject = "rekenen"; const KM_SUBJECTS = ["taal", "rekenen", "world"]; + let stopDashboardLive=()=>{}; + let liveDashboardState="reconnecting"; /* navigatie binnen "School": welk niveau van de boom je ziet. {type:"overview"} - tegels (scholen op "alle scholen", anders @@ -713,6 +715,8 @@ /* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */ function renderToewijzingenPanel(panel){ + stopDashboardLive();stopDashboardLive=()=>{}; + liveDashboardState="reconnecting"; const schoolChosen = currentUser.role!=="super" || !!selSchool; if(!schoolChosen){ panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); @@ -728,17 +732,43 @@ const pupils = USERS.filter(u=>u.role==="pupil"); panel.appendChild(h("div","am-group", T("amDashboardTitle"))); - const dashboardClass = classSel(); + const dashboardClass = classSel(CLASSES.length===1?CLASSES[0].id:""); panel.appendChild(dashboardClass); const dashboardBody = h("div","teacher-dashboard"); panel.appendChild(dashboardBody); - async function loadDashboard(){ + let dashboardSequence=0; + const selectDashboardPupil=pupilId=>{ + pupilSel.value=String(pupilId); + pupilSel.dispatchEvent(new Event("change")); + }; + async function loadDashboard(connect=false){ + const sequence=++dashboardSequence; dashboardBody.innerHTML = ""; - if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; } + if(!dashboardClass.value){ + stopDashboardLive();stopDashboardLive=()=>{}; + dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); + return; + } + if(connect){ + stopDashboardLive(); + liveDashboardState="reconnecting"; + stopDashboardLive=connectLiveClassDashboard( + dashboardClass.value, + ()=>loadDashboard(false), + ()=>dashboardBody.isConnected&&panel.style.display!=="none"&&document.getElementById("settingsWrap").classList.contains("open"), + state=>{liveDashboardState=state;updateLiveConnectionState(dashboardBody,state);} + ); + } dashboardBody.appendChild(h("div","guestnote","…")); try{ - const data = await api("/progress/dashboard/class/"+dashboardClass.value); + const [data,liveData] = await Promise.all([ + api("/progress/dashboard/class/"+dashboardClass.value), + api("/progress/live/class/"+dashboardClass.value), + ]); + if(sequence!==dashboardSequence)return; dashboardBody.innerHTML = ""; + renderLiveSessionOverview(dashboardBody,liveData,selectDashboardPupil); + updateLiveConnectionState(dashboardBody,liveDashboardState); const summary = h("div","dashboard-summary"); ["complete","active","support","notStarted"].forEach(status=>{ const chip = h("div","dashboard-summary-chip "+status); @@ -762,14 +792,17 @@ card.appendChild(h("span","am-role",T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total))); } card.appendChild(h("span","dashboard-score",pupil.attempts ? pupil.accuracy+"% · "+pupil.correct+"/"+pupil.attempts : T("amDashboardNoActivity"))); - card.addEventListener("click",()=>{ pupilSel.value=String(pupil.pupilId); pupilSel.dispatchEvent(new Event("change")); }); + card.addEventListener("click",()=>selectDashboardPupil(pupil.pupilId)); grid.appendChild(card); }); dashboardBody.appendChild(grid); - }catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); } + }catch(e){ + if(sequence!==dashboardSequence)return; + dashboardBody.innerHTML="";dashboardBody.appendChild(h("div","am-msg",e.message)); + } } - dashboardClass.addEventListener("change",loadDashboard); - loadDashboard(); + dashboardClass.addEventListener("change",()=>loadDashboard(true)); + loadDashboard(true); panel.appendChild(h("div","am-group", T("amProgressDetail"))); const pupilSel = h("select","am-sel"); diff --git a/public/js/core.js b/public/js/core.js index 508fb68..25b48af 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.56-beta"; +const VERSION = "0.4.57-beta"; /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en @@ -409,6 +409,10 @@ const I18N = { castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", castPathAdd:"+ Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Sleepvolgorde wordt met de pijlen bepaald.", amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling", + amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…", + amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.", + amLiveNew:"Nieuw begonnen", amLiveWorking:"Live bezig", amLiveEnded:"Recent", amLiveStarted:"Gestart", amLiveLastSeen:"Laatst gezien", + amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} goed", amLiveJustNow:"zojuist", amLiveMinutes:"{count} min. geleden", amLiveHours:"{count} uur geleden", amDashboardStatus_complete:"Klaar", amDashboardStatus_active:"Bezig", amDashboardStatus_support:"Hulp nodig", amDashboardStatus_notStarted:"Nog niet gestart", amDashboardSteps:"{done} van {total} stappen", amDashboardNoActivity:"Nog geen activiteit", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", @@ -807,6 +811,10 @@ const I18N = { castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", castPathAdd:"+ Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.", amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details", + amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…", + amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.", + amLiveNew:"Just started", amLiveWorking:"Working live", amLiveEnded:"Recent", amLiveStarted:"Started", amLiveLastSeen:"Last seen", + amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} correct", amLiveJustNow:"just now", amLiveMinutes:"{count} min ago", amLiveHours:"{count} hr ago", amDashboardStatus_complete:"Complete", amDashboardStatus_active:"Working", amDashboardStatus_support:"Needs help", amDashboardStatus_notStarted:"Not started", amDashboardSteps:"{done} of {total} steps", amDashboardNoActivity:"No activity yet", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", diff --git a/public/js/live-dashboard.js b/public/js/live-dashboard.js new file mode 100644 index 0000000..d278b68 --- /dev/null +++ b/public/js/live-dashboard.js @@ -0,0 +1,124 @@ +/* Live klasdashboard: rendert actieve en recente leersessies en houdt de + weergave via Server-Sent Events actueel. Een 30-secondencontrole is alleen + een vangnet voor tijdelijk weggevallen mobiele/proxyverbindingen. */ +"use strict"; + +function liveSessionTime(value){ + const seconds=Math.max(0,Math.round((Date.now()-new Date(value).getTime())/1000)); + if(seconds<60)return T("amLiveJustNow"); + const minutes=Math.floor(seconds/60); + if(minutes<60)return T("amLiveMinutes").replace("{count}",minutes); + const hours=Math.floor(minutes/60); + return T("amLiveHours").replace("{count}",hours); +} + +function updateLiveConnectionState(host,state){ + const badge=host.querySelector(".live-connection"); + if(!badge)return; + badge.className="live-connection "+state; + badge.textContent=T(state==="connected"?"amLiveConnected":"amLiveReconnecting"); +} + +function renderLiveSessionOverview(host,data,onPupil){ + const section=document.createElement("section"); + section.className="live-session-overview"; + const head=document.createElement("div"); + head.className="live-session-head"; + const title=document.createElement("strong"); + title.textContent=T("amLiveSessions"); + const connection=document.createElement("span"); + connection.className="live-connection connecting"; + connection.textContent=T("amLiveReconnecting"); + head.append(title,connection); + section.appendChild(head); + + const sessions=data.sessions||[]; + const active=sessions.filter(session=>session.status==="live"); + const recent=sessions.filter(session=>session.status!=="live").slice(0,20); + const renderGroup=(label,items,emptyKey)=>{ + const group=document.createElement("div"); + group.className="live-session-group"; + const groupHead=document.createElement("div"); + groupHead.className="live-session-group-head"; + const groupTitle=document.createElement("strong"); + groupTitle.textContent=label; + const count=document.createElement("span"); + count.textContent=String(items.length); + groupHead.append(groupTitle,count); + group.appendChild(groupHead); + if(!items.length){ + const empty=document.createElement("div"); + empty.className="guestnote"; + empty.textContent=T(emptyKey); + group.appendChild(empty); + section.appendChild(group); + return; + } + const grid=document.createElement("div"); + grid.className="live-session-grid"; + items.forEach(session=>{ + const card=document.createElement("button"); + card.type="button"; + const justStarted=session.status==="live" + && new Date(data.serverTime||Date.now()).getTime()-new Date(session.startedAt).getTime()<60000; + card.className="live-session-card "+(session.status==="live"?"live":"recent"); + const status=document.createElement("span"); + status.className="live-session-status"; + status.textContent=T(justStarted?"amLiveNew":session.status==="live"?"amLiveWorking":"amLiveEnded"); + const name=document.createElement("strong"); + name.className="live-session-name"; + name.textContent=session.pupilName; + const widget=document.createElement("span"); + widget.className="live-session-widget"; + widget.textContent=(T("wg_"+session.widgetType)||session.widgetType)+" · "+T(session.mode==="kijken"?"amModeWatch":"amModeWork"); + const time=document.createElement("span"); + time.className="live-session-time"; + time.textContent=T(session.status==="live"?"amLiveStarted":"amLiveLastSeen")+" "+ + liveSessionTime(session.status==="live"?session.startedAt:session.lastSeenAt); + card.append(status,name,widget,time); + if(session.progressCount){ + const score=document.createElement("span"); + score.className="live-session-score"; + score.textContent=T("amLiveUpdates").replace("{count}",session.progressCount); + if(session.attempts)score.textContent+=" · "+T("amLiveScore") + .replace("{correct}",session.correct).replace("{attempts}",session.attempts); + card.appendChild(score); + } + card.addEventListener("click",()=>onPupil(session.pupilId)); + grid.appendChild(card); + }); + group.appendChild(grid); + section.appendChild(group); + }; + renderGroup(T("amLiveNow"),active,"amLiveNone"); + renderGroup(T("amLiveRecent"),recent,"amLiveRecentNone"); + host.appendChild(section); +} + +function connectLiveClassDashboard(classId,onChange,isActive,onState){ + let source=null; + let stopped=false; + let reloadTimer=null; + const queueReload=()=>{ + clearTimeout(reloadTimer); + reloadTimer=setTimeout(()=>{if(!stopped&&isActive())onChange();},180); + }; + if(typeof EventSource!=="undefined"){ + source=new EventSource("/api/progress/live/class/"+encodeURIComponent(classId)+"/stream"); + source.onopen=()=>onState("connected"); + source.onerror=()=>onState("reconnecting"); + source.addEventListener("sessions",queueReload); + } + const fallback=setInterval(()=>{ + if(!isActive()){stop();return;} + onChange(); + },30000); + function stop(){ + if(stopped)return; + stopped=true; + clearInterval(fallback); + clearTimeout(reloadTimer); + if(source)source.close(); + } + return stop; +} diff --git a/public/js/pupil.js b/public/js/pupil.js index 62164e1..21d1761 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -9,17 +9,61 @@ leerkracht vlot doorkomen). */ const PUPIL_WIDGET_CATS = ["taal", "rekenen"]; const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 }; +const PUPIL_LIVE_HEARTBEAT_MS = 12000; const pupilView = document.getElementById("pupilView"); let pupilPollTimer = null; let pupilLastSig = null; let pupilMode = "werken"; let pupilPendingRewards = []; +let pupilLiveSession = null; +let pupilLiveStart = null; +let pupilLiveHeartbeatTimer = null; + +async function endPupilLiveSession(keepalive=false){ + const current=pupilLiveSession; + pupilLiveSession=null;pupilLiveStart=null; + if(!current)return; + const path="/api/my/live-session/"+encodeURIComponent(current.id)+"/end"; + if(keepalive){ + fetch(path,{method:"POST",credentials:"same-origin",keepalive:true}).catch(()=>{}); + return; + } + try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/end",{method:"POST"});} + catch(e){ /* een verlopen sessie is al effectief afgesloten */ } +} +async function ensurePupilLiveSession(widgetId,widgetType,mode){ + const key=widgetId+"|"+widgetType+"|"+mode; + if(pupilLiveSession?.key===key)return pupilLiveSession.id; + if(pupilLiveStart?.key===key)return pupilLiveStart.promise; + if(pupilLiveSession)await endPupilLiveSession(); + const promise=api("/my/live-session/start",{method:"POST",body:{widgetId,widgetType,mode}}) + .then(result=>{ + pupilLiveSession={id:result.session.id,key}; + pupilLiveStart=null; + return pupilLiveSession.id; + }) + .catch(error=>{ + pupilLiveStart=null; + console.warn("live sessie starten mislukt:",error.message); + return null; + }); + pupilLiveStart={key,promise}; + return promise; +} +async function heartbeatPupilLiveSession(){ + const current=pupilLiveSession; + if(!current||document.visibilityState==="hidden")return; + try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/heartbeat",{method:"POST"});} + catch(e){if(pupilLiveSession?.id===current.id)pupilLiveSession=null;} +} +window.addEventListener("pagehide",()=>endPupilLiveSession(true)); function pupilAllowedDefs(){ return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); } async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){ + if(!preview)await endPupilLiveSession(); pupilView.innerHTML = ""; const defs = pupilAllowedDefs(); const watch = mode === "kijken"; @@ -104,6 +148,13 @@ async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){ hebben nog geen wid - dan is positie in de lijst de terugval-identiteit, stabiel zolang de leerkracht de widgets op het bord niet herschikt */ const widgetId = w.wid || (def.id + ":" + idx); + const startLive=()=>ensurePupilLiveSession(widgetId,def.id,mode); + if(!preview){ + card.addEventListener("pointerdown",startLive); + card.addEventListener("focusin",startLive); + card.addEventListener("keydown",startLive); + if(watch&&idx===0)startLive(); + } /* in kijken-modus geen voortgang: de leerling doet zelf niets. In werken-modus moet de leerling juist wél kunnen werken - readonly:true stond hier sinds v0.3.15-beta abusievelijk ook in de werken-tak (een @@ -119,7 +170,9 @@ async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){ "voortgang werkt niet"-klacht onmogelijk te onderzoeken maakt. Nu zichtbaar in de console (geen storende UI voor de leerling, wél een spoor voor wie het probleem moet natrekken). */ - api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) + ensurePupilLiveSession(widgetId,def.id,mode) + .then(liveSessionId=>api("/my/progress", { method:"POST", + body: { widgetId, widgetType: def.id, liveSessionId, ...p } })) .then(()=>{ stepReady = true; if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); } @@ -188,10 +241,15 @@ function startPupilView(){ pupilPoll(); clearInterval(pupilPollTimer); pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[pupilMode]); + clearInterval(pupilLiveHeartbeatTimer); + pupilLiveHeartbeatTimer=setInterval(heartbeatPupilLiveSession,PUPIL_LIVE_HEARTBEAT_MS); } function stopPupilView(){ document.body.classList.remove("pupil-mode"); clearInterval(pupilPollTimer); pupilPollTimer = null; + clearInterval(pupilLiveHeartbeatTimer); + pupilLiveHeartbeatTimer=null; + endPupilLiveSession(); pupilView.innerHTML = ""; } diff --git a/src/api.js b/src/api.js index 43e8291..a87243f 100644 --- a/src/api.js +++ b/src/api.js @@ -17,6 +17,7 @@ import { SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole, } from './auth.js'; import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js'; +import registerLiveSessions from './live-sessions.js'; export default async function api(app) { const pool = app.pg; @@ -991,6 +992,10 @@ export default async function api(app) { if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId); return widgets; }; + const liveSessions = registerLiveSessions(app, { + pool, need, fail, progressRoles: PERMISSIONS['progress.view'], + sameSchool, teacherOnly, classOwnedByTeacher, effectiveAssignment, assignmentWidgets, + }); app.get('/my/assignment', async (req, reply) => { need(req, reply); @@ -1237,7 +1242,7 @@ export default async function api(app) { app.post('/my/progress', async (req, reply) => { need(req, reply); if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); - const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {}; + const { widgetId, widgetType, attempts, correct, stars, detail, liveSessionId } = req.body ?? {}; if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId'); if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType'); let a = await effectiveAssignment(req.user); @@ -1258,10 +1263,13 @@ export default async function api(app) { if (p) a = { teacher_id: p.parent_id, board_id: 'thuis' }; } if (!a) return fail(reply, 400, 'geen toewijzing'); + const cleanAttempts = clampCount(attempts); + const cleanCorrect = clampCount(correct); await pool.query( `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, - [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]); + [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, cleanAttempts, cleanCorrect, clampCount(stars), sanitizeProgressDetail(detail)]); + await liveSessions.recordProgress(req.user.id, liveSessionId, cleanAttempts, cleanCorrect); return { ok: true }; }); const describeProgressEvent = (e) => ({ diff --git a/src/live-sessions.js b/src/live-sessions.js new file mode 100644 index 0000000..f2c0719 --- /dev/null +++ b/src/live-sessions.js @@ -0,0 +1,198 @@ +// Persistente live leersessies + Server-Sent Events voor het klasdashboard. +// De database is de bron van waarheid. SSE verstuurt alleen een ververssignaal, +// zodat reconnects en gemiste mobiele netwerkberichten geen status verliezen. + +const LIVE_WINDOW_MS = 45_000; + +export default function registerLiveSessions(app, options) { + const { + pool, need, fail, progressRoles, sameSchool, teacherOnly, + classOwnedByTeacher, effectiveAssignment, assignmentWidgets, + } = options; + const classStreams = new Map(); + + const publishClass = (classId) => { + const streams = classStreams.get(String(classId)); + if (!streams) return; + for (const stream of [...streams]) { + try { stream.write('event: sessions\ndata: {}\n\n'); } + catch { streams.delete(stream); } + } + if (!streams.size) classStreams.delete(String(classId)); + }; + + const describe = (row) => { + const lastSeenAt = row.last_seen_at || row.started_at; + const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS; + return { + id: Number(row.id), + pupilId: Number(row.pupil_id), + pupilName: row.display_name || row.username, + widgetId: row.widget_id, + widgetType: row.widget_type, + mode: row.mode, + startedAt: row.started_at, + lastSeenAt, + endedAt: row.ended_at || null, + progressCount: Number(row.progress_count || 0), + attempts: Number(row.attempts || 0), + correct: Number(row.correct || 0), + status: live ? 'live' : 'recent', + }; + }; + + const accessibleClass = async (req, reply) => { + const row = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!row) return { response: fail(reply, 404, 'klas onbekend') }; + if (!sameSchool(req, row)) return { response: fail(reply, 403, 'geen rechten') }; + if (teacherOnly(req) && !(await classOwnedByTeacher(row.id, req.user.id))) + return { response: fail(reply, 403, 'geen rechten') }; + return { row }; + }; + + const snapshot = async (classId) => { + const rows = (await pool.query( + `SELECT ls.*, u.display_name, u.username + FROM live_learning_sessions ls JOIN users u ON u.id = ls.pupil_id + WHERE ls.class_id = $1 + AND COALESCE(ls.last_seen_at, ls.started_at) >= now() - interval '24 hours' + ORDER BY + CASE WHEN ls.ended_at IS NULL AND ls.last_seen_at >= now() - interval '45 seconds' THEN 0 ELSE 1 END, + COALESCE(ls.last_seen_at, ls.started_at) DESC + LIMIT 80`, [classId])).rows; + const sessions = rows.map(describe); + return { + sessions, + liveCount: sessions.filter((session) => session.status === 'live').length, + serverTime: new Date().toISOString(), + }; + }; + + app.post('/my/live-session/start', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!req.user.class_id) return fail(reply, 400, 'leerling heeft geen klas'); + const { widgetId, widgetType, mode } = req.body ?? {}; + if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) + return fail(reply, 400, 'ongeldige widgetId'); + if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) + return fail(reply, 400, 'ongeldige widgetType'); + if (mode !== 'werken' && mode !== 'kijken') return fail(reply, 400, 'ongeldige modus'); + + const assignment = await effectiveAssignment(req.user); + if (!assignment) return fail(reply, 400, 'geen toewijzing'); + const steps = Array.isArray(assignment.sequence) ? assignment.sequence : []; + let boardId = assignment.board_id; + let assignedWidgetId = assignment.widget_id; + let assignedMode = assignment.mode || 'werken'; + if (steps.length) { + const completed = new Set((await pool.query( + 'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows.map((row) => row.step_id)); + const current = steps.find((step) => !completed.has(step.id)); + if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond'); + boardId = current.boardId; + assignedWidgetId = current.widgetId; + assignedMode = current.mode; + } + if (assignedWidgetId && assignedWidgetId !== widgetId) + return fail(reply, 409, 'deze widget is niet de huidige opdracht'); + if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd'); + + const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId); + const known = widgets.some((widget) => + widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true)); + if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing'); + + await pool.query( + `UPDATE live_learning_sessions + SET ended_at = now(), last_seen_at = now() + WHERE pupil_id = $1 AND ended_at IS NULL`, [req.user.id]); + await pool.query( + `DELETE FROM live_learning_sessions + WHERE pupil_id = $1 AND started_at < now() - interval '30 days'`, [req.user.id]); + const row = (await pool.query( + `INSERT INTO live_learning_sessions + (pupil_id, teacher_id, class_id, assignment_id, board_id, widget_id, widget_type, mode) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [req.user.id, assignment.teacher_id, req.user.class_id, assignment.id, + boardId, widgetId, widgetType, mode])).rows[0]; + publishClass(req.user.class_id); + return { session: describe({ ...row, display_name: req.user.display_name, username: req.user.username }) }; + }); + + app.post('/my/live-session/:id/heartbeat', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie'); + const row = (await pool.query( + `UPDATE live_learning_sessions SET last_seen_at = now() + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING id`, + [req.params.id, req.user.id])).rows[0]; + if (!row) return fail(reply, 404, 'sessie niet actief'); + return { ok: true }; + }); + + app.post('/my/live-session/:id/end', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie'); + const row = (await pool.query( + `UPDATE live_learning_sessions SET ended_at = now(), last_seen_at = now() + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING class_id`, + [req.params.id, req.user.id])).rows[0]; + if (row) publishClass(row.class_id); + return { ok: true }; + }); + + app.get('/progress/live/class/:id', async (req, reply) => { + need(req, reply, progressRoles); + const access = await accessibleClass(req, reply); + if (!access.row) return access.response; + return snapshot(access.row.id); + }); + + app.get('/progress/live/class/:id/stream', async (req, reply) => { + need(req, reply, progressRoles); + const access = await accessibleClass(req, reply); + if (!access.row) return access.response; + reply.hijack(); + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('event: connected\ndata: {}\n\n'); + const key = String(access.row.id); + if (!classStreams.has(key)) classStreams.set(key, new Set()); + classStreams.get(key).add(reply.raw); + const heartbeat = setInterval(() => { + try { reply.raw.write(': heartbeat\n\n'); } catch { /* close ruimt op */ } + }, 15_000); + heartbeat.unref?.(); + const cleanup = () => { + clearInterval(heartbeat); + const streams = classStreams.get(key); + if (streams) { + streams.delete(reply.raw); + if (!streams.size) classStreams.delete(key); + } + }; + req.raw.once('close', cleanup); + }); + + return { + async recordProgress(pupilId, sessionId, attempts, correct) { + if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return; + const row = (await pool.query( + `UPDATE live_learning_sessions + SET last_seen_at = now(), progress_count = progress_count + 1, + attempts = attempts + $3, correct = correct + $4 + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL + RETURNING class_id`, + [sessionId, pupilId, attempts, correct])).rows[0]; + if (row) publishClass(row.class_id); + }, + }; +} diff --git a/test/live-sessions.test.js b/test/live-sessions.test.js new file mode 100644 index 0000000..2f2e9ae --- /dev/null +++ b/test/live-sessions.test.js @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +const cookies = { teach_session: 'x'.repeat(64) }; +const boardData = { + boards: { folders: [{ boards: [{ + id: 'board-live', name: 'Live bord', + data: { widgets: [{ id: 'letters', wid: 'letters-1', state: { words: ['boom'] } }] }, + }] }] }, +}; + +async function makeApp(user, query) { + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', { query }); + await app.register(api, { prefix: '/api' }); + await app.ready(); + return app; +} + +function auth(user, sql) { + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + return null; +} + +test('echte widgetinteractie start een beveiligde live leersessie', async () => { + const pupil = { + id: 9, username: 'lena', display_name: 'Lena', role: 'pupil', + school_id: 2, class_id: 50, data: {}, data_rev: 0, + }; + const assignment = { + id: 80, class_id: 50, teacher_id: 3, board_id: 'board-live', + widget_id: 'letters-1', mode: 'werken', sequence: [], + }; + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + const a = auth(pupil, sql); + if (a) return a; + if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; + if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: boardData }] }; + if (sql.includes('INSERT INTO live_learning_sessions')) return { rows: [{ + id: 101, pupil_id: 9, teacher_id: 3, class_id: 50, assignment_id: 80, + board_id: 'board-live', widget_id: 'letters-1', widget_type: 'letters', + mode: 'werken', started_at: new Date(), last_seen_at: new Date(), + }] }; + return { rows: [] }; + }; + const app = await makeApp(pupil, query); + const response = await app.inject({ + method: 'POST', url: '/api/my/live-session/start', cookies, + payload: { widgetId: 'letters-1', widgetType: 'letters', mode: 'werken' }, + }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(response.json().session.status, 'live'); + const insert = calls.find((call) => call.sql.includes('INSERT INTO live_learning_sessions')); + assert.deepEqual(insert.params, [9, 3, 50, 80, 'board-live', 'letters-1', 'letters', 'werken']); + assert.ok(calls.some((call) => call.sql.includes('SET ended_at = now()')), + 'een vorige open sessie wordt eerst recent gemaakt'); + await app.close(); +}); + +test('voortgang houdt dezelfde live sessie direct actueel', async () => { + const pupil = { + id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, + data: {}, data_rev: 0, + }; + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + const a = auth(pupil, sql); + if (a) return a; + if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') + return { rows: [{ id: 80, teacher_id: 3, board_id: 'board-live', sequence: [] }] }; + if (sql.includes('INSERT INTO progress_events')) return { rows: [] }; + if (sql.includes('progress_count = progress_count + 1')) return { rows: [{ class_id: 50 }] }; + return { rows: [] }; + }; + const app = await makeApp(pupil, query); + const response = await app.inject({ + method: 'POST', url: '/api/my/progress', cookies, + payload: { + widgetId: 'letters-1', widgetType: 'letters', attempts: 4, correct: 3, + stars: 1, liveSessionId: 101, + }, + }); + assert.equal(response.statusCode, 200, response.body); + const update = calls.find((call) => call.sql.includes('progress_count = progress_count + 1')); + assert.deepEqual(update.params, [101, 9, 4, 3]); + await app.close(); +}); + +test('leerkracht ziet nieuwe actieve en meest recente sessies van de eigen klas', async () => { + const teacher = { + id: 3, username: 'juf', role: 'teacher', school_id: 2, + class_id: null, data: boardData, data_rev: 0, + }; + const now = new Date(); + const query = async (sql) => { + const a = auth(teacher, sql); + if (a) return a; + if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; + if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] }; + if (sql.includes('FROM live_learning_sessions ls')) return { rows: [ + { + id: 101, pupil_id: 9, display_name: 'Lena', widget_id: 'letters-1', + widget_type: 'letters', mode: 'werken', started_at: now, last_seen_at: now, + progress_count: 1, attempts: 2, correct: 2, ended_at: null, + }, + { + id: 100, pupil_id: 8, display_name: 'Sam', widget_id: 'math-1', + widget_type: 'madd', mode: 'werken', started_at: new Date(now - 120000), + last_seen_at: new Date(now - 60000), progress_count: 2, attempts: 4, + correct: 3, ended_at: new Date(now - 60000), + }, + ] }; + return { rows: [] }; + }; + const app = await makeApp(teacher, query); + const response = await app.inject({ + method: 'GET', url: '/api/progress/live/class/50', cookies, + }); + assert.equal(response.statusCode, 200, response.body); + const body = response.json(); + assert.equal(body.liveCount, 1); + assert.deepEqual(body.sessions.map((session) => session.status), ['live', 'recent']); + assert.equal(body.sessions[0].pupilName, 'Lena'); + await app.close(); +}); + +test('groepsleiding kan geen live sessies van een andere klas volgen', async () => { + const teacher = { + id: 3, username: 'juf', role: 'teacher', school_id: 2, + class_id: null, data: boardData, data_rev: 0, + }; + const query = async (sql) => { + const a = auth(teacher, sql); + if (a) return a; + if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 51, school_id: 2 }] }; + if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [] }; + return { rows: [] }; + }; + const app = await makeApp(teacher, query); + const response = await app.inject({ + method: 'GET', url: '/api/progress/live/class/51', cookies, + }); + assert.equal(response.statusCode, 403); + await app.close(); +}); + +test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying', async () => { + const [migration, server, pupil, dashboard, admin, html, nginx, core, version] = await Promise.all([ + readFile('db/022_live_learning_sessions.sql', 'utf8'), + readFile('src/live-sessions.js', 'utf8'), + readFile('public/js/pupil.js', 'utf8'), + readFile('public/js/live-dashboard.js', 'utf8'), + readFile('public/js/admin.js', 'utf8'), + readFile('public/index.html', 'utf8'), + readFile('deploy/nginx.conf', 'utf8'), + readFile('public/js/core.js', 'utf8'), + readFile('VERSION', 'utf8'), + ]); + for (const column of ['started_at', 'last_seen_at', 'ended_at', 'progress_count']) + assert.ok(migration.includes(column), column); + assert.match(server, /text\/event-stream/); + assert.match(server, /event: sessions/); + assert.match(server, /interval '24 hours'/); + assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/); + assert.match(pupil, /PUPIL_LIVE_HEARTBEAT_MS/); + assert.match(pupil, /liveSessionId/); + assert.match(dashboard, /new EventSource/); + assert.match(dashboard, /setInterval[\s\S]*30000/); + assert.match(admin, /progress\/live\/class/); + assert.ok(html.indexOf('js/live-dashboard.js') < html.indexOf('js/admin.js')); + assert.match(nginx, /location \/api\/progress\/live\/[\s\S]*proxy_buffering off/); + for (const key of ['amLiveSessions', 'amLiveNew', 'amLiveRecent', 'amLiveConnected']) + assert.equal((core.match(new RegExp(key + ':', 'g')) || []).length, 2, key); + assert.ok(core.includes('const VERSION = "' + version.trim() + '"')); +});