feat: voeg live leersessies toe aan klasdashboard
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m38s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m38s
This commit is contained in:
parent
29b12e6c81
commit
90851f960a
12 changed files with 686 additions and 13 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.56-beta
|
||||
0.4.57-beta
|
||||
|
|
|
|||
25
db/022_live_learning_sessions.sql
Normal file
25
db/022_live_learning_sessions.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
<script src="js/board.js?v=__V__" defer></script>
|
||||
<script src="js/pupil.js?v=__V__" defer></script>
|
||||
<script src="js/parent.js?v=__V__" defer></script>
|
||||
<script src="js/live-dashboard.js?v=__V__" defer></script>
|
||||
<script src="js/admin.js?v=__V__" defer></script>
|
||||
<script src="js/cast.js?v=__V__" defer></script>
|
||||
<script src="js/image-catalog.js?v=__V__" defer></script>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
124
public/js/live-dashboard.js
Normal file
124
public/js/live-dashboard.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = "";
|
||||
}
|
||||
|
|
|
|||
12
src/api.js
12
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) => ({
|
||||
|
|
|
|||
198
src/live-sessions.js
Normal file
198
src/live-sessions.js
Normal file
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
186
test/live-sessions.test.js
Normal file
186
test/live-sessions.test.js
Normal file
|
|
@ -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() + '"'));
|
||||
});
|
||||
Loading…
Reference in a new issue