v0.3.09-beta: voeg voortgang-schema en -API toe

- Nieuwe migratie db/007_progress.sql: progress_events-tabel (per leerling
  per widget-moment: pogingen, goed, sterren)
- POST /my/progress: leerling logt een afgerond moment; teacher_id/board_id
  worden server-side afgeleid uit de bestaande toewijzing, nooit van de client
- GET /progress/pupil/:id en GET /progress/class/🆔 leerkracht-weergave,
  gated met nieuwe progress.view-permissie en dezelfde scoping als assignments
- Permissie progress.view toegevoegd in beide permissions.js-bestanden
This commit is contained in:
Ramon 2026-07-14 14:51:50 +02:00
parent a20779e602
commit e3369e34a2
6 changed files with 79 additions and 2 deletions

View file

@ -1 +1 @@
0.3.08-beta 0.3.09-beta

19
db/007_progress.sql Normal file
View file

@ -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);

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;

View file

@ -13,6 +13,7 @@ const PERMISSIONS = {
'users.genpw': ['super', 'admin', 'teacher'], 'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'], 'users.role.change': ['super'],
'assignments.manage': ['super', 'admin', 'teacher'], 'assignments.manage': ['super', 'admin', 'teacher'],
'progress.view': ['super', 'admin', 'teacher'],
}; };
const CREATABLE_ROLES = { const CREATABLE_ROLES = {

View file

@ -559,6 +559,62 @@ export default async function api(app) {
const widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, state: w.state })); const widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, state: w.state }));
return { widgets, updatedAt: a.updated_at }; 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. // Eerste super-beheerder aanmaken als die nog niet bestaat.

View file

@ -15,6 +15,7 @@ export const PERMISSIONS = {
'users.genpw': ['super', 'admin', 'teacher'], 'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'], 'users.role.change': ['super'],
'assignments.manage': ['super', 'admin', 'teacher'], 'assignments.manage': ['super', 'admin', 'teacher'],
'progress.view': ['super', 'admin', 'teacher'],
}; };
// Welke rol een account met welke rol mag aanmaken. // Welke rol een account met welke rol mag aanmaken.