diff --git a/VERSION b/VERSION index 5f54142..6234113 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.08-beta +0.3.09-beta diff --git a/db/007_progress.sql b/db/007_progress.sql new file mode 100644 index 0000000..efd35d2 --- /dev/null +++ b/db/007_progress.sql @@ -0,0 +1,19 @@ +-- Voortgang: leerlingen loggen elk afgerond "moment" in een widget (ronde +-- sommen, voltooid woord, geknapte ballon) zodat leerkrachten een tijdlijn +-- per leerling kunnen bekijken. teacher_id/board_id komen server-side uit de +-- bestaande assignments-tabel, nooit van de client, om te voorkomen dat een +-- leerling voortgang onder een andere leerkracht kan wegschrijven. +CREATE TABLE IF NOT EXISTS progress_events ( + 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, + board_id TEXT NOT NULL, + widget_id TEXT NOT NULL, + widget_type TEXT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + correct INT NOT NULL DEFAULT 0, + stars INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_progress_events_pupil ON progress_events(pupil_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_progress_events_teacher ON progress_events(teacher_id, created_at DESC); diff --git a/public/js/core.js b/public/js/core.js index e7f111b..dca86c4 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.08-beta"; +const VERSION = "0.3.09-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/permissions.js b/public/js/permissions.js index 7aa90d9..4bf42c8 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -13,6 +13,7 @@ const PERMISSIONS = { 'users.genpw': ['super', 'admin', 'teacher'], 'users.role.change': ['super'], 'assignments.manage': ['super', 'admin', 'teacher'], + 'progress.view': ['super', 'admin', 'teacher'], }; const CREATABLE_ROLES = { diff --git a/src/api.js b/src/api.js index e283a6a..636b077 100644 --- a/src/api.js +++ b/src/api.js @@ -559,6 +559,62 @@ export default async function api(app) { const widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, state: w.state })); return { widgets, updatedAt: a.updated_at }; }); + + // ---- voortgang (leerling-omgeving) --------------------------------------------- + // Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid + // woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve + // toewijzing (net als /my/assignment), nooit van de client, zodat een leerling geen + // voortgang onder een andere leerkracht kan wegschrijven. + const PROGRESS_LIMIT = 200; + const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0)); + 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 } = 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 pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; + if (!a && req.user.class_id) { + a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; + } + if (!a) return fail(reply, 400, 'geen toewijzing'); + await pool.query( + `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars)]); + return { ok: true }; + }); + const describeProgressEvent = (e) => ({ + widgetId: e.widget_id, + widgetType: e.widget_type, + attempts: e.attempts, + correct: e.correct, + stars: e.stars, + createdAt: e.created_at, + }); + app.get('/progress/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['progress.view']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const rows = (await pool.query( + 'SELECT * FROM progress_events WHERE pupil_id = $1 ORDER BY created_at DESC LIMIT $2', + [u.id, PROGRESS_LIMIT])).rows; + return { events: rows.map(describeProgressEvent) }; + }); + app.get('/progress/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['progress.view']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const rows = (await pool.query( + `SELECT pe.*, u.display_name, u.username FROM progress_events pe + JOIN users u ON u.id = pe.pupil_id + WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`, + [c.id, PROGRESS_LIMIT])).rows; + return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; + }); } // Eerste super-beheerder aanmaken als die nog niet bestaat. diff --git a/src/permissions.js b/src/permissions.js index f8eb4ec..d086646 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -15,6 +15,7 @@ export const PERMISSIONS = { 'users.genpw': ['super', 'admin', 'teacher'], 'users.role.change': ['super'], 'assignments.manage': ['super', 'admin', 'teacher'], + 'progress.view': ['super', 'admin', 'teacher'], }; // Welke rol een account met welke rol mag aanmaken.