From b28d25296d83464dc6105ef7e638540f3bbd2f13 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 16:19:25 +0200 Subject: [PATCH] v0.3.17-beta: detecteer save-conflicten tussen tabbladen - Nieuwe migratie db/009: users.data_rev + data_updated_at en de user_data_versions-tabel (voor de komende versiegeschiedenis) - GET /me/data geeft nu {data, rev}; PUT /me/data controleert een optionele x-data-rev-header en weigert een verouderde schrijver met 409 (zonder header blijft het oude laatste-schrijver-wint-gedrag voor oude clients) - core.js: rev wordt bijgehouden en meegestuurd; bij 409 toont de opslaan- knop "Elders bewerkt" en haalt een klik eerst de nieuwste serverversie op in plaats van te overschrijven - beforeunload-waarschuwing bij een openstaande, lopende of mislukte save - Nieuwe test test/save-system.test.js (rev mee, 409, legacy zonder header) --- VERSION | 2 +- db/009_save_system.sql | 16 +++++++++ public/js/core.js | 61 +++++++++++++++++++++++++------ src/api.js | 29 ++++++++++++--- test/save-system.test.js | 78 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 db/009_save_system.sql create mode 100644 test/save-system.test.js diff --git a/VERSION b/VERSION index b5d33cb..055fd99 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.16-beta +0.3.17-beta diff --git a/db/009_save_system.sql b/db/009_save_system.sql new file mode 100644 index 0000000..13cded7 --- /dev/null +++ b/db/009_save_system.sql @@ -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); diff --git a/public/js/core.js b/public/js/core.js index 1d7738c..2e2480f 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.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(); }); diff --git a/src/api.js b/src/api.js index 2d003c6..57b23b6 100644 --- a/src/api.js +++ b/src/api.js @@ -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) --------------------------------------------------------- diff --git a/test/save-system.test.js b/test/save-system.test.js new file mode 100644 index 0000000..d0e4710 --- /dev/null +++ b/test/save-system.test.js @@ -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(); +});