// 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.`); }