teach/src/api.js
Ramon b4eee9f78f
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 52s
feat: gebruikers aan een school koppelen en losmaken (v0.3.90-beta)
- PATCH /admin/users/:id ondersteunt schoolId (alleen systeemmanager, rollen teacher/admin/pupil): null = losmaken, anders koppelen met bestaanscheck (404); bij wissel worden klas, klaskoppelingen en (voor leerlingen) oude toewijzingen opgeruimd; naamconflicten in doelschool of school-loze naamruimte geven een nette 409
- Detailpaneel toont voor de systeemmanager een School-veld bij deze rollen - losse accounts (ooit aan een school gekoppeld) zijn hiermee terug te zetten en bewust los te maken; werkt ook in de beheerder-subrijen op het Scholen-tabblad
- Gebruikers-tab (alle scholen): losse accounts staan in een eigen inklapbare groep "Zonder school (n)" i.p.v. onopvallend tussen de rolgroepen; de oudergroep is nu ook inklapbaar
- 3 nieuwe servertests (koppelen incl. opruiming, losmaken, 404, geen rechten/no-op voor admin en parent, 409 bij naamconflict) + 10 Playwright-checks voor de volledige flow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 14:30:41 +02:00

1260 lines
69 KiB
JavaScript

// teach API: inloggen, rollen en gebruikersbeheer.
//
// Rollen en rechten:
// super - systeemmanager: alles, over alle scholen heen,
// kan andere systeemmanagers aanwijzen.
// admin - schoolbeheerder: alle gebruikers en klassen van de eigen school.
// teacher - groepsleiding: leerlingen aanmaken in eigen klas, leerlingen
// verplaatsen naar andere klassen (uitwisselen), leerling-
// wachtwoorden inzien en aanpassen.
// pupil - leerling: inloggen en eigen borden; kan het eigen wachtwoord
// NIET inzien of wijzigen.
// parent - ouder (school_id NULL): eigen registratie, beheert gekoppelde
// kind-accounts en ziet hun voortgang; geen beheer, geen borden.
import {
hashPassword, verifyPassword, createSession, userFromRequest,
newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole,
} from './auth.js';
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
export default async function api(app) {
const pool = app.pg;
// Voorkomt meetbaar snellere antwoorden voor niet-bestaande gebruikers.
const dummyPasswordHash = await hashPassword(newLinkCode());
const cookieSecure = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production';
const cookieOptions = { path: '/', httpOnly: true, secure: cookieSecure, sameSite: 'strict' };
const setSessionCookie = (reply, token, role) => reply.setCookie(SESSION_COOKIE, token, {
...cookieOptions, maxAge: sessionDaysForRole(role) * 24 * 60 * 60,
});
const clearSessionCookie = (reply) => reply.clearCookie(SESSION_COOKIE, { path: '/' });
// ---- helpers --------------------------------------------------------------
const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; };
app.decorateRequest('user', null);
app.addHook('preHandler', async (req, reply) => {
if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) {
const expectedOrigin = `${req.protocol}://${req.headers.host}`;
if (req.headers.origin !== expectedOrigin) return fail(reply, 403, 'ongeldige origin');
}
req.user = await userFromRequest(pool, req);
});
app.addHook('onResponse', async (req, reply) => {
if ([401, 403, 429].includes(reply.statusCode)) {
req.log.warn({ securityEvent: true, status: reply.statusCode, method: req.method,
url: req.url, ip: req.ip, userId: req.user?.id ?? null }, 'security-relevant response');
}
});
const need = (req, reply, roles) => {
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); }
if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); }
};
// groepsleiding-only regels gelden alleen als iemand *uitsluitend* teacher is;
// wie er (via een extra rol) ook admin of super bij heeft, valt onder de
// ruimere regels van die rol i.p.v. de leerling-only-beperking van teacher.
const teacherOnly = (req) =>
req.user.allRoles.includes('teacher')
&& !req.user.allRoles.includes('admin')
&& !req.user.allRoles.includes('super');
// welke rollen iemand mag aanmaken: unie over al hun rollen (hoofd + extra)
const creatableRoles = (req) =>
[...new Set(req.user.allRoles.flatMap((r) => CREATABLE_ROLES[r] || []))];
const sameSchool = (req, u) =>
req.user.allRoles.includes('super') || Number(u.school_id) === Number(req.user.school_id);
app.setErrorHandler((err, req, reply) => {
if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message });
req.log.error(err);
reply.code(500).send({ error: 'serverfout' });
});
// ---- version --------------------------------------------------------------
app.get('/version', async () => ({
name: 'teach',
version: process.env.APP_VERSION ?? 'dev',
sha: process.env.GIT_SHA || undefined,
}));
// ---- publiek: scholenlijst voor het inlogscherm ----------------------------
app.get('/schools', async () => {
const r = await pool.query('SELECT id, name FROM schools ORDER BY name');
return r.rows.map((s) => ({ id: Number(s.id), name: s.name }));
});
// ---- site-inhoud (CMS van de systeemmanager) --------------------------------
// Kleine sleutel/waarde-opslag: welkomstwoord (inlogscherm), update-melding
// (banner voor alle gebruikers) en aanvullende widget-uitleg (galerij).
// Lezen is publiek (het inlogscherm toont het welkomstwoord al vóór het
// inloggen); schrijven kan alleen de systeemmanager, per toegestane sleutel
// en met een maat-limiet zodat dit nooit een opslagplaats voor blobs wordt.
const CONTENT_KEYS = { welcome: 8 * 1024, announcement: 8 * 1024, widgetInfo: 64 * 1024 };
app.get('/content', async () => {
const r = await pool.query('SELECT key, value FROM site_content');
const out = {};
r.rows.forEach((row) => { out[row.key] = row.value; });
return out;
});
app.put('/admin/content/:key', async (req, reply) => {
need(req, reply, PERMISSIONS['content.manage']);
const key = req.params.key;
if (!(key in CONTENT_KEYS)) return fail(reply, 404, 'onbekende inhoud');
const value = req.body ?? {};
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(reply, 400, 'ongeldige inhoud');
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > CONTENT_KEYS[key]) return fail(reply, 413, 'inhoud te groot');
await pool.query(
`INSERT INTO site_content (key, value, updated_at, updated_by) VALUES ($1, $2, now(), $3)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now(), updated_by = $3`,
[key, value, req.user.id]);
return { ok: true };
});
// ---- auth -------------------------------------------------------------------
app.post('/auth/login', {
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
}, async (req, reply) => {
const { schoolId, username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string'
|| !username.trim() || username.length > 30 || password.length > 128)
return fail(reply, 400, 'ongeldige naam of wachtwoord');
const r = schoolId
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username.trim()])
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username.trim()]);
const u = r.rows[0];
const passwordOk = await verifyPassword(password, u?.password_hash || dummyPasswordHash);
if (!u || !passwordOk) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
// account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk
await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = await createSession(pool, u.id, u.role);
setSessionCookie(reply, token, u.role);
return { user: publicUser(u) };
});
// Account activeren met een koppelcode (groepsleiding/beheerders)
app.post('/auth/link', {
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
}, async (req, reply) => {
const { code, password } = req.body ?? {};
if (typeof code !== 'string' || code.length > 20 || typeof password !== 'string' || password.length > 128)
return fail(reply, 400, 'ongeldige koppelcode of wachtwoord');
const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
const err = checkPassword(password, u.role);
if (err) return fail(reply, 400, err);
const hash = await hashPassword(password);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]);
const token = await createSession(pool, u.id, u.role);
setSessionCookie(reply, token, u.role);
return { user: publicUser({ ...u, password_hash: hash, link_code: null }) };
});
app.post('/auth/logout', async (req, reply) => {
const token = sessionTokenFromRequest(req);
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [hashSessionToken(token)]);
clearSessionCookie(reply);
return { ok: true };
});
app.get('/auth/me', async (req, reply) => {
if (!req.user) return fail(reply, 401, 'niet ingelogd');
return { user: publicUser(req.user) };
});
// Eigen wachtwoord wijzigen - niet voor leerlingen
app.patch('/auth/password', {
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (req, reply) => {
need(req, reply, PERMISSIONS['password.own.change']);
const { currentPassword, password } = req.body ?? {};
if (typeof currentPassword !== 'string' || typeof password !== 'string') return fail(reply, 400, 'ongeldig wachtwoord');
if (!(await verifyPassword(currentPassword, req.user.password_hash))) return fail(reply, 401, 'huidig wachtwoord onjuist');
const err = checkPassword(password, req.user.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
[await hashPassword(password), req.user.id]);
const token = sessionTokenFromRequest(req);
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND token <> $2',
[req.user.id, token ? hashSessionToken(token) : '']);
return { ok: true };
});
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
app.get('/me/data', async (req, reply) => {
need(req, reply);
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;
};
// Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar
// hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en
// maximaal 10 versies per gebruiker.
const SNAPSHOT_MIN_AGE = "10 minutes";
const SNAPSHOT_KEEP = 10;
const snapshotUserData = async (userId) => {
const fresh = (await pool.query(
`SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`,
[userId, SNAPSHOT_MIN_AGE])).rows[0];
if (fresh) return;
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[userId]);
await pool.query(
`DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN (
SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
)`, [userId, SNAPSHOT_KEEP]);
};
const writeUserData = async (req, reply, data) => {
await snapshotUserData(req.user.id);
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) };
};
// schoolleerlingen hebben geen eigen borden-data; thuiskinderen (pupil
// zonder school, ouderportaal) wél - die mogen ook hun reservekopieën zien
const schoolPupil = (req) => req.user.role === 'pupil' && req.user.school_id != null;
app.get('/me/data/versions', async (req, reply) => {
need(req, reply);
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const rows = (await pool.query(
'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id])).rows;
return { versions: rows.map((v) => ({ id: Number(v.id), createdAt: v.created_at })) };
});
app.post('/me/data/restore', async (req, reply) => {
need(req, reply);
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const { versionId } = req.body ?? {};
if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie');
const v = (await pool.query(
'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2',
[versionId, req.user.id])).rows[0];
if (!v) return fail(reply, 404, 'versie onbekend');
// de huidige data eerst zelf als versie bewaren, zodat een herstel ook
// weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel)
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[req.user.id]);
const r = await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
[v.data, req.user.id]);
return { data: v.data, 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');
return writeUserData(req, reply, data);
});
// Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set)
// i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de
// volledige PUT hierboven, die blijft bestaan voor restore/eerste save.
const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2, anchors: 5 }; // cap in MB
app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply);
const section = req.params.section;
if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie');
const part = req.body ?? {};
// anchors is een lijst, de overige secties zijn objecten
if (!part || typeof part !== 'object') return fail(reply, 400, 'ongeldige data');
if (Array.isArray(part) !== (section === 'anchors')) return fail(reply, 400, 'ongeldige data');
if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024)
return fail(reply, 413, 'data te groot');
await snapshotUserData(req.user.id);
const rev = revFromRequest(req);
const set = `data = jsonb_set(coalesce(data, '{}'::jsonb), $1, $2::jsonb), data_rev = data_rev + 1, data_updated_at = now()`;
const r = rev === null
? await pool.query(`UPDATE users SET ${set} WHERE id = $3 RETURNING data_rev`,
[`{${section}}`, JSON.stringify(part), req.user.id])
: await pool.query(`UPDATE users SET ${set} WHERE id = $3 AND data_rev = $4 RETURNING data_rev`,
[`{${section}}`, JSON.stringify(part), 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) };
});
// ---- scholen (super) ---------------------------------------------------------
app.post('/admin/schools', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {};
if (!name || typeof name !== 'string' || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.patch('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'school onbekend');
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.delete('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
return { ok: true };
});
// Multireset: alle leerlingwachtwoorden van een hele school in één keer
// vernieuwen. Alleen schoolbeheerder (eigen school) en systeemmanager.
app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.resetPasswords']);
const schoolId = req.params.id;
if (!req.user.allRoles.includes('super') && Number(schoolId) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query(
"SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows;
const users = [];
for (const p of pupils) {
const pw = pupilPassword();
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(pw), p.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
}
return { users };
});
// ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.view']);
const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id;
if (!schoolId) return { classes: [] };
const r = await pool.query(
`SELECT c.id, c.name,
COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids
FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]);
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
});
app.post('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const { name, school } = req.body ?? {};
const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id;
if (!name || typeof name !== 'string' || name.trim().length > 80 || !schoolId) return fail(reply, 400, 'ongeldige naam of school');
const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]);
return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.delete('/admin/classes/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'klas onbekend');
if (!req.user.allRoles.includes('super') && Number(r.rows[0].school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]);
return { ok: true };
});
// groepsleiding aan klas koppelen/loskoppelen
// Elke staf-rol (groepsleiding, schoolbeheerder, systeemmanager) kan aan
// een klas gekoppeld worden; alleen leerlingen niet (die horen al bij één
// klas via users.class_id). Een systeemmanager is school-onafhankelijk en
// mag dus aan klassen van elke school gekoppeld worden.
app.post('/admin/classes/:id/teachers', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const { userId, remove } = req.body ?? {};
if (!userId) return fail(reply, 400, 'gebruiker verplicht');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [userId])).rows[0];
if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend');
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
if (!['teacher', 'admin', 'super'].includes(u.role)) return fail(reply, 400, 'alleen staf kan aan een klas gekoppeld worden');
if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id))
return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen');
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]);
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]);
return { ok: true };
});
// Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen.
// Alleen schoolbeheerder (eigen school) en systeemmanager - niet
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
app.post('/admin/classes/:id/reset-passwords', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
const c = r.rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query(
"SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows;
const users = [];
for (const p of pupils) {
const pw = pupilPassword();
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(pw), p.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
}
return { users };
});
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
app.get('/admin/genpw', async (req, reply) => {
need(req, reply, PERMISSIONS['users.genpw']);
const role = req.query.role === 'pupil' ? 'pupil' : 'staff';
return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
});
// ---- gebruikers ---------------------------------------------------------------
// Lijst: super ziet alles (optioneel per school), admin de eigen school,
// teacher de eigen school maar leerlingen alleen uit de eigen klas(sen).
app.get('/admin/users', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
// Ouders hebben school_id NULL en vallen daardoor buiten elke schoollijst;
// een school ziet wél de ouders die aan haar eigen leerlingen gekoppeld zijn.
const linkedParents = async (schoolId, extra = '', params = []) => (await pool.query(
`SELECT DISTINCT p.* FROM users p
JOIN parent_children pc ON pc.parent_id = p.id
JOIN users k ON k.id = pc.pupil_id
WHERE k.school_id = $1 ${extra} ORDER BY lower(p.username)`,
[schoolId, ...params])).rows;
let rows;
if (req.user.allRoles.includes('super')) {
rows = (req.query.school
? await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.query.school])
: await pool.query('SELECT * FROM users ORDER BY school_id NULLS FIRST, role, lower(username)')).rows;
if (req.query.school) rows = rows.concat(await linkedParents(req.query.school));
} else if (teacherOnly(req)) {
rows = (await pool.query(
`SELECT * FROM users
WHERE school_id = $1
AND (role != 'pupil' OR class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2))
ORDER BY role, lower(username)`,
[req.user.school_id, req.user.id])).rows;
rows = rows.concat(await linkedParents(req.user.school_id,
'AND k.class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2)', [req.user.id]));
} else {
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows;
rows = rows.concat(await linkedParents(req.user.school_id));
}
// extra stafrollen per gebruiker erbij ophalen (in één keer, niet per rij)
const roleRows = rows.length
? (await pool.query('SELECT user_id, role FROM user_roles WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
: [];
const extraByUser = new Map();
roleRows.forEach((r) => {
const key = String(r.user_id);
extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]);
});
// klas-koppelingen van groepsleiding (class_teachers), ook in één keer
const ctRows = rows.length
? (await pool.query('SELECT user_id, class_id FROM class_teachers WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
: [];
const classesByUser = new Map();
ctRows.forEach((r) => {
const key = String(r.user_id);
classesByUser.set(key, [...(classesByUser.get(key) || []), Number(r.class_id)]);
});
// kindernamen bij ouder-rijen, zodat de school ziet wie bij wie hoort
const parentIds = rows.filter((u) => u.role === 'parent').map((u) => u.id);
const kidRows = parentIds.length
? (await pool.query(
`SELECT pc.parent_id, k.display_name, k.username FROM parent_children pc
JOIN users k ON k.id = pc.pupil_id WHERE pc.parent_id = ANY($1)`, [parentIds])).rows
: [];
const kidsByParent = new Map();
kidRows.forEach((r) => {
const key = String(r.parent_id);
kidsByParent.set(key, [...(kidsByParent.get(key) || []), r.display_name || r.username]);
});
return { users: rows.map((u) => ({
...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }),
linkCode: u.link_code || null,
everLoggedIn: !!u.last_login_at,
classIds: classesByUser.get(String(u.id)) || [],
...(u.role === 'parent' ? { childNames: kidsByParent.get(String(u.id)) || [] } : {}),
})) };
});
// Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school.
// teacher: alleen leerlingen in de eigen school.
app.post('/admin/users', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const b = req.body ?? {};
const role = b.role;
if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
const schoolId = req.user.allRoles.includes('super') ? (role === 'super' ? null : b.school) : req.user.school_id;
if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
const username = (b.username || '').trim();
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
try {
if (role === 'pupil') {
if (b.classId != null) {
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
if (!c || Number(c.school_id) !== Number(schoolId)) return fail(reply, 400, 'klas hoort niet bij deze school');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
}
const pw = b.password || pupilPassword();
const err = checkPassword(pw, 'pupil');
if (err) return fail(reply, 400, err);
const r = await pool.query(
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash)
VALUES ($1,'pupil',$2,$3,$4,$5) RETURNING *`,
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw)]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
// groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of
// gegenereerd), ofwel de klassieke koppelcode om zelf te activeren.
if (b.password || b.generatePassword) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, role);
if (err) return fail(reply, 400, err);
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, password_hash)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, await hashPassword(pw)]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
const code = newLinkCode();
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, link_code)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, code]);
return { user: { ...publicUser(r.rows[0]), linkCode: code } };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
});
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
app.patch('/admin/users/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen');
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
const b = req.body ?? {};
if (b.displayName) {
if (typeof b.displayName !== 'string' || b.displayName.trim().length > 80) return fail(reply, 400, 'ongeldige weergavenaam');
await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName.trim(), u.id]);
}
if (b.classId !== undefined && u.role === 'pupil') {
if (b.classId !== null) {
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
if (!c || Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
}
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
}
if (b.password && u.role === 'pupil') {
const err = checkPassword(b.password, 'pupil');
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(b.password), u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
}
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, u.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
return { ok: true, password: pw };
}
if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
const code = newLinkCode();
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
return { ok: true, linkCode: code };
}
// school koppelen/losmaken (alleen systeemmanager): losse accounts kunnen
// teruggezet worden naar een school en bewust losgemaakt. Super en parent
// zijn per definitie schoolloos - daarvoor is dit een stille no-op.
if (b.schoolId !== undefined && req.user.allRoles.includes('super')
&& ['teacher', 'admin', 'pupil'].includes(u.role)) {
const target = b.schoolId === null ? null : Number(b.schoolId);
if (target !== null) {
const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0];
if (!school) return fail(reply, 404, 'school onbekend');
}
if (target !== (u.school_id == null ? null : Number(u.school_id))) {
try {
await pool.query('UPDATE users SET school_id = $1, class_id = NULL WHERE id = $2', [target, u.id]);
} catch (e) {
// naam kan botsen in de doelschool óf (bij losmaken) in de
// school-loze naamruimte (systeemmanagers/ouders/thuiskinderen)
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
await pool.query('DELETE FROM class_teachers WHERE user_id = $1', [u.id]);
// een oude individuele toewijzing verwijst naar een bord van de vorige school
if (u.role === 'pupil') await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
}
}
// rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven
// leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren
if (b.role && can(req.user.allRoles, 'users.role.change')
&& ['super', 'admin', 'teacher'].includes(b.role)
&& ['super', 'admin', 'teacher'].includes(u.role))
await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3',
[b.role, b.role === 'super' ? null : u.school_id, u.id]);
// extra functies naast de hoofdrol (bv. een teacher die ook admin-rechten krijgt) -
// alleen voor staf, zelfde recht als hoofdrol-wijziging; hoofdrol zelf telt niet
// ook nog als "extra"
if (b.extraRoles !== undefined && can(req.user.allRoles, 'users.role.change') && u.role !== 'pupil') {
const extra = [...new Set(b.extraRoles)].filter((r) => ['super', 'admin', 'teacher'].includes(r) && r !== u.role);
await pool.query('DELETE FROM user_roles WHERE user_id = $1', [u.id]);
for (const role of extra) {
await pool.query('INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING', [u.id, role]);
}
return { ok: true, extraRoles: extra };
}
return { ok: true };
});
app.delete('/admin/users/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
return { ok: true };
});
// ---- toewijzingen (leerling-omgeving) ------------------------------------------
// Een leerkracht koppelt een eigen bord (via het stabiele board-id) aan een klas
// (standaard voor iedereen erin) of een individuele leerling (uitzondering, voor
// wie op een ander niveau werkt). Leerling-eigen koppeling wint van de klas-
// koppeling. Geen momentopname: het bord wordt live opgezocht in de data van de
// leerkracht, dus nieuwe woorden die de leerkracht toevoegt komen vanzelf door.
const classOwnedByTeacher = async (classId, userId) =>
!!(await pool.query('SELECT 1 FROM class_teachers WHERE class_id = $1 AND user_id = $2', [classId, userId])).rows[0];
const findBoardById = (data, boardId) => {
const folders = data?.boards?.folders || [];
for (const fo of folders) {
const bo = (fo.boards || []).find((b) => b.id === boardId);
if (bo) return bo;
}
return null;
};
const describeAssignment = async (a) => {
const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
const board = t ? findBoardById(t.data, a.board_id) : null;
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
return {
teacherId: Number(a.teacher_id),
teacherName: t ? (t.display_name || t.username) : null,
boardId: a.board_id,
boardName: board ? board.name : null,
widgetId: a.widget_id || null,
widgetType: widget ? widget.id : null,
mode: a.mode || 'werken',
updatedAt: a.updated_at,
};
};
// Gedeelde validatie voor beide PUT-routes: heel bord (widgetId null, bestaand
// gedrag) of één widget van dat bord, met modus kijken/werken. De widget moet
// echt in het opgegeven bord van de leerkracht zelf staan.
const parseShareBody = (req, reply) => {
const { boardId, widgetId, mode } = req.body ?? {};
if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId');
const board = findBoardById(req.user.data, boardId);
if (!board) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker');
const m = mode ?? 'werken';
if (m !== 'kijken' && m !== 'werken') return fail(reply, 400, 'ongeldige modus');
if (widgetId != null) {
if (typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
if (!(board.data?.widgets || []).some((w) => w.wid === widgetId)) return fail(reply, 400, 'widget onbekend op dit bord');
}
return { boardId, widgetId: widgetId ?? null, mode: m };
};
app.get('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
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 a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [c.id])).rows[0];
return { assignment: a ? await describeAssignment(a) : null };
});
app.put('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseShareBody(req, reply);
if (share.error) return share;
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');
await pool.query(
`INSERT INTO assignments (class_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (class_id) WHERE class_id IS NOT NULL
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
[c.id, req.user.id, share.boardId, share.widgetId, share.mode]);
return { ok: true };
});
app.delete('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
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');
await pool.query('DELETE FROM assignments WHERE class_id = $1', [c.id]);
return { ok: true };
});
const pupilAccessible = async (req, u) => {
if (!u || u.role !== 'pupil' || !sameSchool(req, u)) return false;
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) return false;
return true;
};
app.get('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
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 a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [u.id])).rows[0];
return { assignment: a ? await describeAssignment(a) : null };
});
app.put('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseShareBody(req, reply);
if (share.error) return share;
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');
await pool.query(
`INSERT INTO assignments (pupil_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (pupil_id) WHERE pupil_id IS NOT NULL
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
[u.id, req.user.id, share.boardId, share.widgetId, share.mode]);
return { ok: true };
});
app.delete('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
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');
await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
return { ok: true };
});
// Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
// Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen
// doorkomen zodra de leerling-pagina opnieuw ophaalt (polling).
app.get('/my/assignment', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
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 { widgets: [], mode: 'werken', updatedAt: null };
const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
const board = t ? findBoardById(t.data, a.board_id) : null;
let widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, wid: w.wid, state: w.state }));
// één-widget-toewijzing: alleen die widget; is hij inmiddels van het bord
// verwijderd, dan een lege lijst (zelfde weergave als geen toewijzing)
if (a.widget_id) widgets = widgets.filter((w) => w.wid === a.widget_id);
return { widgets, mode: a.mode || 'werken', 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];
}
// thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig,
// de voortgang wordt gelogd met de (eerste) gekoppelde ouder als
// "leerkracht" zodat het ouderportaal de tijdlijn direct kan tonen
if (!a && !req.user.school_id) {
const p = (await pool.query(
'SELECT parent_id FROM parent_children WHERE pupil_id = $1 ORDER BY parent_id LIMIT 1',
[req.user.id])).rows[0];
if (p) a = { teacher_id: p.parent_id, board_id: 'thuis' };
}
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,
});
// Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder
// gelden de normale staf-regels (progress.view + pupilAccessible).
const parentLinked = async (parentId, pupilId) => !!(await pool.query(
'SELECT 1 FROM parent_children WHERE parent_id = $1 AND pupil_id = $2',
[parentId, pupilId])).rows[0];
app.get('/progress/pupil/:id', async (req, reply) => {
need(req, reply);
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 (req.user.role === 'parent') {
if (!(await parentLinked(req.user.id, u.id))) return fail(reply, 403, 'geen rechten');
} else {
if (!can(req.user.allRoles, 'progress.view')) return fail(reply, 403, 'geen rechten');
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 })) };
});
// ---- ouderportaal -----------------------------------------------------------
// Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school,
// beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school.
// De school houdt de regie over aanmeldverzoeken (adopteren/koppelen/afwijzen).
const needParent = (req, reply) => {
need(req, reply);
if (req.user.role !== 'parent') { reply.code(403); throw new Error('alleen voor ouders'); }
};
const USERNAME_RE = /^[a-zA-Z0-9_.-]{2,30}$/;
app.post('/auth/register-parent', {
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
}, async (req, reply) => {
const { username, password } = req.body ?? {};
const name = typeof username === 'string' ? username.trim() : '';
if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige gebruikersnaam');
const err = checkPassword(password, 'parent');
if (err) return fail(reply, 400, err);
try {
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, password_hash, last_login_at)
VALUES ($1,'parent',NULL,$1,$2,now()) RETURNING *`,
[name, await hashPassword(password)]);
const u = r.rows[0];
const token = await createSession(pool, u.id, u.role);
setSessionCookie(reply, token, u.role);
return { user: publicUser(u) };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
});
// Gekoppelde kinderen, met school-/klasnaam en eventuele aanmeldstatus.
app.get('/parent/children', async (req, reply) => {
needParent(req, reply);
const rows = (await pool.query(
`SELECT u.id, u.username, u.display_name, u.school_id, u.class_id,
s.name AS school_name, c.name AS class_name
FROM parent_children pc JOIN users u ON u.id = pc.pupil_id
LEFT JOIN schools s ON s.id = u.school_id
LEFT JOIN classes c ON c.id = u.class_id
WHERE pc.parent_id = $1 ORDER BY lower(u.username)`, [req.user.id])).rows;
const reqs = (await pool.query(
`SELECT r.pupil_id, s.name AS school_name
FROM school_link_requests r JOIN schools s ON s.id = r.school_id
WHERE r.parent_id = $1`, [req.user.id])).rows;
const pendingByPupil = new Map(reqs.map((r) => [String(r.pupil_id), r.school_name]));
return { children: rows.map((u) => ({
id: Number(u.id),
username: u.username,
displayName: u.display_name || u.username,
schoolId: u.school_id == null ? null : Number(u.school_id),
schoolName: u.school_name || null,
className: u.class_name || null,
appliedTo: pendingByPupil.get(String(u.id)) || null,
})) };
});
// Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld.
// Het gegenereerde wachtwoord wordt eenmalig teruggegeven.
app.post('/parent/children', async (req, reply) => {
needParent(req, reply);
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige naam (2-30 tekens, letters/cijfers)');
const pw = pupilPassword();
try {
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, password_hash)
VALUES ($1,'pupil',NULL,$1,$2) RETURNING *`,
[name, await hashPassword(pw)]);
const u = r.rows[0];
await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2)', [req.user.id, u.id]);
return { child: { ...publicUser(u), password: pw } };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
});
// Schoolkind koppelen met de eenmalige ouder-code die de school uitgeeft.
app.post('/parent/children/link', {
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
}, async (req, reply) => {
needParent(req, reply);
const { code } = req.body ?? {};
if (typeof code !== 'string' || !code.trim() || code.length > 20) return fail(reply, 400, 'ongeldige code');
const u = (await pool.query(
"SELECT * FROM users WHERE parent_code = $1 AND role = 'pupil'",
[code.trim().toUpperCase()])).rows[0];
if (!u) return fail(reply, 404, 'code onbekend of al gebruikt');
await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[req.user.id, u.id]);
await pool.query('UPDATE users SET parent_code = NULL WHERE id = $1', [u.id]);
return { child: publicUser(u) };
});
// Thuiskind aanmelden bij een school: er ontstaat een aanmeldverzoek dat de
// school afhandelt; dubbel aanmelden bij dezelfde school geeft een 409.
app.post('/parent/children/:id/apply', async (req, reply) => {
needParent(req, reply);
const { schoolId } = req.body ?? {};
if (!schoolId) return fail(reply, 400, 'school verplicht');
if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'kind onbekend');
if (u.school_id != null) return fail(reply, 400, 'dit kind zit al op een school');
const s = (await pool.query('SELECT * FROM schools WHERE id = $1', [schoolId])).rows[0];
if (!s) return fail(reply, 404, 'school onbekend');
try {
await pool.query('INSERT INTO school_link_requests (school_id, parent_id, pupil_id) VALUES ($1,$2,$3)',
[s.id, req.user.id, u.id]);
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'al aangemeld bij deze school');
throw e;
}
return { ok: true };
});
// Alleen de koppeling verwijderen; het kind-account zelf blijft bestaan.
app.delete('/parent/children/:id', async (req, reply) => {
needParent(req, reply);
await pool.query('DELETE FROM parent_children WHERE parent_id = $1 AND pupil_id = $2',
[req.user.id, req.params.id]);
return { ok: true };
});
// Staf genereert per leerling een eenmalige ouder-code; de respons toont
// hem één keer, daarna is hij alleen nog te vernieuwen (oude vervalt).
app.post('/admin/users/:id/parent-code', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
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 code = newLinkCode();
await pool.query('UPDATE users SET parent_code = $1 WHERE id = $2', [code, u.id]);
return { ok: true, parentCode: code };
});
// Openstaande aanmeldverzoeken voor de eigen school (super: gekozen school).
const requestSchoolId = (req) =>
req.user.allRoles.includes('super') ? req.query?.school : req.user.school_id;
app.get('/admin/link-requests', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const schoolId = requestSchoolId(req);
if (!schoolId) return { requests: [] };
const rows = (await pool.query(
`SELECT r.id, r.created_at,
k.id AS pupil_id, k.username AS pupil_username, k.display_name AS pupil_name,
o.username AS parent_username, o.display_name AS parent_name
FROM school_link_requests r
JOIN users k ON k.id = r.pupil_id
JOIN users o ON o.id = r.parent_id
WHERE r.school_id = $1 ORDER BY r.created_at`, [schoolId])).rows;
return { requests: rows.map((r) => ({
id: Number(r.id),
createdAt: r.created_at,
pupil: { id: Number(r.pupil_id), username: r.pupil_username, displayName: r.pupil_name || r.pupil_username },
parent: { username: r.parent_username, displayName: r.parent_name || r.parent_username },
})) };
});
// Afhandelen van een aanmeldverzoek:
// * adopt - het thuisaccount wordt de schoolleerling (school + klas op
// hetzelfde account; borden/voortgang/ouderkoppeling blijven).
// Bij een naamconflict in de school kan staf direct een andere
// gebruikersnaam meegeven.
// * match - de ouderkoppeling verhuist naar een bestaande schoolleerling;
// het thuisaccount wordt alleen opgeruimd als het leeg is (geen
// borddata), zodat er nooit werk verloren gaat.
// * reject - verzoek verdwijnt, verder verandert er niets.
app.post('/admin/link-requests/:id/resolve', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const r = (await pool.query('SELECT * FROM school_link_requests WHERE id = $1', [req.params.id])).rows[0];
if (!r) return fail(reply, 404, 'aanmelding onbekend');
if (!req.user.allRoles.includes('super') && Number(r.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
const b = req.body ?? {};
if (b.action === 'reject') {
await pool.query('DELETE FROM school_link_requests WHERE id = $1', [r.id]);
return { ok: true };
}
if (b.action === 'adopt') {
let classId = null;
if (b.classId != null) {
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
if (!c || Number(c.school_id) !== Number(r.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
classId = c.id;
}
let username = null;
if (b.username != null) {
username = String(b.username).trim();
if (!USERNAME_RE.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
}
try {
await pool.query(
`UPDATE users SET school_id = $1, class_id = $2, username = COALESCE($3, username)
WHERE id = $4 AND role = 'pupil'`,
[r.school_id, classId, username, r.pupil_id]);
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school');
throw e;
}
// het kind hoort nu bij deze school: ook aanmeldingen bij andere
// scholen zijn daarmee vervallen
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
return { ok: true };
}
if (b.action === 'match') {
if (!b.pupilId) return fail(reply, 400, 'leerling verplicht');
const target = (await pool.query('SELECT * FROM users WHERE id = $1', [b.pupilId])).rows[0];
if (!target || target.role !== 'pupil' || Number(target.school_id) !== Number(r.school_id))
return fail(reply, 400, 'leerling hoort niet bij deze school');
await pool.query(
`INSERT INTO parent_children (parent_id, pupil_id)
SELECT parent_id, $1 FROM parent_children WHERE pupil_id = $2
ON CONFLICT DO NOTHING`, [target.id, r.pupil_id]);
const home = (await pool.query(
`SELECT (data IS NULL OR data = '{}'::jsonb) AS empty FROM users WHERE id = $1`,
[r.pupil_id])).rows[0];
if (home?.empty) {
// leeg thuisaccount opruimen (cascade ruimt koppelingen en verzoeken op)
await pool.query('DELETE FROM users WHERE id = $1', [r.pupil_id]);
} else {
// thuisaccount met werk erin blijft bestaan; alleen de verzoeken weg
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
}
return { ok: true };
}
return fail(reply, 400, 'onbekende actie');
});
// ---- gedeelde bibliotheek --------------------------------------------------
// Systeembreed (school_id NULL, gepubliceerd door de systeemmanager, voor
// iedereen zichtbaar) of per school (alleen zichtbaar binnen die school).
// Andere gebruikers KOPIËREN een item naar hun eigen collectie; er wordt
// niet live in gedeelde items gewerkt. Beheren (hernoemen/verplaatsen/
// verwijderen) mag de systeemmanager altijd, de eigenaar altijd, en de
// schoolbeheerder voor items van de eigen school.
const SHARED_KINDS = ['board', 'anchor'];
const sharedStaff = (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); }
};
// Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij,
// alleen de schoolbeheerder en de systeemmanager nog.
const canManageShared = (req, item) =>
req.user.allRoles.includes('super')
|| (item.school_id != null && req.user.allRoles.includes('admin')
&& Number(item.school_id) === Number(req.user.school_id))
|| (Number(item.owner_id) === Number(req.user.id) && !item.readonly);
// Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd,
// de schoolbeheerder voor items van de eigen school (niet de leerkracht).
const canLockShared = (req, item) =>
req.user.allRoles.includes('super')
|| (item.school_id != null && req.user.allRoles.includes('admin')
&& Number(item.school_id) === Number(req.user.school_id));
const sharedItemVisible = (req, item) =>
req.user.allRoles.includes('super')
|| item.school_id == null
|| Number(item.school_id) === Number(req.user.school_id);
const validSharedName = (n) => typeof n === 'string' && n.trim() && n.trim().length <= 40;
const validSharedFolder = (f) => typeof f === 'string' && f.length <= 200 && !f.includes('//') && !/[\x00-\x1f]/.test(f);
const describeShared = (req, it) => ({
id: Number(it.id),
name: it.name,
folder: it.folder || '',
scope: it.school_id == null ? 'global' : 'school',
ownerName: it.owner_name || null,
readonly: !!it.readonly,
canManage: canManageShared(req, it),
canLock: canLockShared(req, it),
});
app.get('/shared/:kind', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const rows = req.user.allRoles.includes('super')
? (await pool.query(
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows
: (await pool.query(
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2)
ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows;
return { items: rows.map((it) => describeShared(req, it)) };
});
app.get('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
return { item: { ...describeShared(req, it), data: it.data } };
});
app.post('/shared/:kind', { bodyLimit: 6 * 1024 * 1024 }, async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const { name, folder, data, scope } = req.body ?? {};
if (!validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige data');
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 5 * 1024 * 1024) return fail(reply, 413, 'data te groot');
let schoolId = null;
if (scope === 'global') {
need(req, reply, PERMISSIONS['shared.global.manage']);
} else if (scope === 'school') {
need(req, reply, PERMISSIONS['shared.school.manage']);
if (req.user.school_id == null) return fail(reply, 400, 'geen school gekoppeld aan dit account');
schoolId = req.user.school_id;
} else return fail(reply, 400, 'ongeldige scope');
const r = await pool.query(
`INSERT INTO shared_library (school_id, owner_id, kind, folder, name, data)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[schoolId, req.user.id, kind, folder || '', name.trim(), data]);
return { ok: true, id: Number(r.rows[0].id) };
});
// map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map
// op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de
// gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door
// wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager)
app.patch('/shared/:kind/folder', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const { scope, from, to, readonly } = req.body ?? {};
if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope');
if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map');
if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen');
if (to !== undefined && (!validSharedFolder(to) || !to)) return fail(reply, 400, 'ongeldige map');
const rows = scope === 'global'
? (await pool.query(
`SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`,
[kind, from, from + '/%'])).rows
: (await pool.query(
`SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`,
[kind, req.user.school_id, from, from + '/%'])).rows;
if (to !== undefined){
if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map');
await pool.query(
`UPDATE shared_library SET folder = $1 || substr(folder, $2)
WHERE id = ANY($3)`,
[to, from.length + 1, rows.map((it) => it.id)]);
}
if (typeof readonly === 'boolean'){
if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]);
}
return { ok: true };
});
app.patch('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
const { name, folder, readonly } = req.body ?? {};
if (name !== undefined || folder !== undefined){
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]);
if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]);
}
if (readonly !== undefined){
if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde');
if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]);
}
return { ok: true };
});
app.delete('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM shared_library WHERE id = $1', [it.id]);
return { ok: true };
});
}
// Eerste super-beheerder aanmaken als die nog niet bestaat.
export async function bootstrapSuper(pool, log) {
const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'");
if (r.rows[0].n > 0) return;
const username = process.env.SUPER_USER || 'beheerder';
const password = process.env.SUPER_PASS;
if (!password) throw new Error('SUPER_PASS is verplicht bij het aanmaken van de eerste super-beheerder');
const passwordError = checkPassword(password, 'super');
if (passwordError) throw new Error(`ongeldige SUPER_PASS: ${passwordError}`);
await pool.query(
`INSERT INTO users (username, role, display_name, password_hash)
VALUES ($1, 'super', $1, $2)`,
[username, await hashPassword(password)]);
log.warn(`Eerste super-beheerder aangemaakt: ${username}. Het wachtwoord wordt niet gelogd.`);
}