Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
5 changed files with 170 additions and 16 deletions
Showing only changes of commit b28d25296d - Show all commits

View file

@ -1 +1 @@
0.3.16-beta
0.3.17-beta

16
db/009_save_system.sql Normal file
View file

@ -0,0 +1,16 @@
-- Opslaansysteem: optimistische concurrency + versiegeschiedenis.
-- data_rev telt elke wijziging van users.data; een client stuurt de rev mee
-- die hij geladen heeft en de update slaagt alleen als die nog actueel is
-- (anders 409: een ander tabblad/digibord schreef eerder). user_data_versions
-- bewaart periodieke snapshots van de vorige data zodat een leerkracht een
-- eerdere versie kan terugzetten.
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_rev BIGINT NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_updated_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS user_data_versions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_data_versions ON user_data_versions(user_id, created_at DESC);

View file

@ -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.3.16-beta";
const VERSION = "0.3.17-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -189,7 +189,8 @@ const I18N = {
errPass:"Wachtwoord: minimaal 4 tekens.",
errCrypto:"Inloggen werkt niet in deze browser.",
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen",
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen"
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen",
saveConflict:"⚠ Elders bewerkt", saveConflictHint:"In een ander tabblad bewerkt. Klik om de nieuwste versie te laden (jouw niet-opgeslagen wijzigingen hier vervallen)."
},
en:{
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
@ -351,7 +352,8 @@ const I18N = {
errPass:"Password: at least 4 characters.",
errCrypto:"Login does not work in this browser.",
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first",
saving:"Saving…", saveFailed:"⚠ Not saved"
saving:"Saving…", saveFailed:"⚠ Not saved",
saveConflict:"⚠ Edited elsewhere", saveConflictHint:"Edited in another tab. Click to load the latest version (your unsaved changes here will be lost)."
}
};
let LANG = "nl";
@ -398,7 +400,7 @@ try{ localStorage.removeItem("teach.token"); }catch(e){}
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
async function api(path, opts={}){
const headers = { "Content-Type": "application/json" };
const headers = { "Content-Type": "application/json", ...(opts.headers||{}) };
const r = await fetch("/api" + path, {
credentials: "same-origin",
method: opts.method || (opts.body ? "POST" : "GET"),
@ -406,7 +408,11 @@ async function api(path, opts={}){
body: opts.body ? JSON.stringify(opts.body) : undefined
});
const j = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(j.error || ("fout " + r.status));
if(!r.ok){
const err = new Error(j.error || ("fout " + r.status));
err.status = r.status;
throw err;
}
return j;
}
@ -418,17 +424,22 @@ async function api(path, opts={}){
==========================================================*/
let saveTimer = null;
let lastSaveError = null;
/* rev van de data zoals geladen van de server; gaat als x-data-rev mee met
elke save zodat de server een verouderde schrijver kan weigeren (409) */
let DATA_REV = 0;
let saveConflict = false;
let saveInFlight = false;
function canPersist(){
return !!currentUser && currentUser.role !== "pupil";
}
function persistUser(){
if(!canPersist()) return Promise.resolve(true);
return api("/me/data", { method:"PUT", body:{
return api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
}}).then(()=>{ lastSaveError = null; return true; })
.catch(e=>{ lastSaveError = e.message; return false; });
}}).then(res=>{ lastSaveError = null; saveConflict = false; if(typeof res.rev === "number") DATA_REV = res.rev; return true; })
.catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; });
}
/* status op de opslaan-knop: idle / saving / saved / error */
/* status op de opslaan-knop: idle / saving / saved / error / conflict */
function setSaveStatus(state){
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
@ -436,6 +447,11 @@ function setSaveStatus(state){
btn.title = "";
if(state==="saving"){ lbl.textContent = T("saving"); }
else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); }
else if(state==="conflict"){
lbl.textContent = T("saveConflict");
btn.classList.add("err");
btn.title = T("saveConflictHint");
}
else if(state==="error"){
lbl.textContent = T("saveFailed");
btn.classList.add("err");
@ -446,15 +462,25 @@ function setSaveStatus(state){
let saveStatusTimer = null;
async function doSave(){
if(!canPersist()) return false;
clearTimeout(saveTimer);
saveTimer = null;
stashCurrent();
setSaveStatus("saving");
saveInFlight = true;
const ok = await persistUser();
saveInFlight = false;
clearTimeout(saveStatusTimer);
setSaveStatus(ok ? "saved" : "error");
setSaveStatus(ok ? "saved" : (saveConflict ? "conflict" : "error"));
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
return ok;
}
/* niet weg-navigeren met onopgeslagen werk: geplande/lopende save of een
mislukte laatste save betekent dat de server nog niet alles heeft */
window.addEventListener("beforeunload", (e)=>{
if(!canPersist()) return;
if(saveTimer || saveInFlight || lastSaveError){ e.preventDefault(); e.returnValue = ""; }
});
/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */
function scheduleSave(){
if(!canPersist()) return;
@ -478,7 +504,9 @@ async function hydrateFromServer(){
return;
}
stopPupilView();
const d = (await api("/me/data")).data || {};
const res = await api("/me/data");
const d = res.data || {};
DATA_REV = typeof res.rev === "number" ? res.rev : 0;
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
BS = boardsFromData(d);
@ -663,6 +691,17 @@ document.getElementById("btnSave").addEventListener("click", async ()=>{
loginWrap.classList.add("open");
return;
}
/* na een conflict (409) niet blind overschrijven: de klik haalt eerst de
nieuwste serverversie op, daarna kan er weer gewoon opgeslagen worden */
if(saveConflict){
saveConflict = false;
lastSaveError = null;
clearTimeout(saveTimer);
saveTimer = null;
await hydrateFromServer();
setSaveStatus("idle");
return;
}
clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */
await doSave();
});

View file

@ -153,16 +153,37 @@ export default async function api(app) {
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
app.get('/me/data', async (req, reply) => {
need(req, reply);
const r = await pool.query('SELECT data FROM users WHERE id = $1', [req.user.id]);
return { data: r.rows[0]?.data ?? {} };
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
});
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
// (oude clients blijven werken, laatste schrijver wint dan zoals voorheen).
const revFromRequest = (req) => {
const h = req.headers['x-data-rev'];
if (h === undefined) return null;
const n = Number(h);
return Number.isInteger(n) && n >= 0 ? n : null;
};
const writeUserData = async (req, reply, data) => {
const rev = revFromRequest(req);
const r = rev === null
? await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
[data, req.user.id])
: await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev',
[data, req.user.id, rev]);
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
return { ok: true, rev: Number(r.rows[0].data_rev) };
};
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply);
const data = req.body ?? {};
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [data, req.user.id]);
return { ok: true };
return writeUserData(req, reply, data);
});
// ---- scholen (super) ---------------------------------------------------------

78
test/save-system.test.js Normal file
View file

@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
// test de rev-voorwaarde en het 409-pad kan controleren.
function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 7, username: 'docent', display_name: 'Docent', role: 'teacher',
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
}] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
if (sql.startsWith('UPDATE users SET data =')) {
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
}
return { rows: [] };
},
};
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const sessionCookie = { teach_session: 'x'.repeat(64) };
test('GET /me/data geeft data én rev terug', async () => {
const { app } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { data: { boards: {} }, rev: 3 });
await app.close();
});
test('PUT /me/data met actuele rev slaagt en geeft de nieuwe rev', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.json().rev, 4);
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(upd.sql.includes('AND data_rev = $3'));
assert.equal(upd.params[2], 3);
await app.close();
});
test('PUT /me/data met verouderde rev geeft 409', async () => {
const { app } = await makeApp({ dataRev: 5, updateSucceeds: false });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 409, res.body);
assert.match(res.json().error, /ander tabblad/);
await app.close();
});
test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(!upd.sql.includes('AND data_rev'));
await app.close();
});