From 9d83e13bc33cf14b9db21daf5fe45cde8be821d3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 00:02:16 +0200 Subject: [PATCH] feat: server-kant ouderportaal (v0.3.57-beta) - Nieuwe rol parent (school_id NULL): registreert zichzelf via POST /auth/register-parent (rate-limited, stafwachtwoordregels, directe sessie) - Migratie 013: parent_children-koppeltabel, eenmalige users.parent_code, school_link_requests (aanmeldverzoeken, uniek per kind+school) - Ouderroutes: kinderen bekijken (met school/klas/aanmeldstatus), thuiskind aanmaken (pupil zonder school, wachtwoord eenmalig terug), koppelen via ouder-code, aanmelden bij school, ontkoppelen - Staf: ouder-code genereren per leerling, aanmeldingen bekijken en afhandelen (adopteren in klas met optionele nieuwe gebruikersnaam, koppelen aan bestaande leerling met opruiming van lege thuisaccounts, afwijzen) - Voortgang: thuiskind zonder toewijzing logt met de gekoppelde ouder als leerkracht (bord "thuis"); gekoppelde ouders mogen de voortgang van hun kind opvragen - 18 nieuwe servertests (registratie, koppelmodel, aanmeldflow, resolve-acties, voortgang) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- db/013_parent_portal.sql | 30 ++++ public/js/core.js | 2 +- src/api.js | 257 ++++++++++++++++++++++++++++- test/parent-portal.test.js | 321 +++++++++++++++++++++++++++++++++++++ 5 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 db/013_parent_portal.sql create mode 100644 test/parent-portal.test.js diff --git a/VERSION b/VERSION index c605a1f..da7d5af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.56-beta +0.3.57-beta diff --git a/db/013_parent_portal.sql b/db/013_parent_portal.sql new file mode 100644 index 0000000..3ab038c --- /dev/null +++ b/db/013_parent_portal.sql @@ -0,0 +1,30 @@ +-- v0.3.57-beta: ouderportaal. +-- Ouders (role 'parent', school_id NULL) koppelen aan kind-accounts (pupils): +-- * parent_children: de koppeling zelf (een kind kan meerdere ouders hebben). +-- * users.parent_code: eenmalige code waarmee staf een schoolleerling aan een +-- ouder koppelt (code vervalt na gebruik). +-- * school_link_requests: aanmeldverzoek van een thuiskind (pupil zonder +-- school) bij een school; de school handelt het verzoek af (adopteren, +-- koppelen aan een bestaande leerling of afwijzen). + +CREATE TABLE IF NOT EXISTS parent_children ( + parent_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (parent_id, pupil_id) +); +CREATE INDEX IF NOT EXISTS idx_parent_children_pupil ON parent_children(pupil_id); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS parent_code TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_parent_code + ON users (parent_code) WHERE parent_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS school_link_requests ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + school_id BIGINT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + parent_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (pupil_id, school_id) +); +CREATE INDEX IF NOT EXISTS idx_school_link_requests_school ON school_link_requests(school_id); diff --git a/public/js/core.js b/public/js/core.js index b4fcfec..21babf9 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.56-beta"; +const VERSION = "0.3.57-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/src/api.js b/src/api.js index fe7f0a8..4b7bcc2 100644 --- a/src/api.js +++ b/src/api.js @@ -9,6 +9,8 @@ // 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, @@ -717,6 +719,15 @@ export default async function api(app) { 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) @@ -732,11 +743,21 @@ export default async function api(app) { 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, PERMISSIONS['progress.view']); + 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 (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + 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; @@ -756,6 +777,238 @@ export default async function api(app) { 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). diff --git a/test/parent-portal.test.js b/test/parent-portal.test.js new file mode 100644 index 0000000..1406440 --- /dev/null +++ b/test/parent-portal.test.js @@ -0,0 +1,321 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +// Gemockte pool: sessie-join levert `user`, daarna beantwoordt `respond` +// (per test) de queries; alles wat die niet afvangt geeft lege rows. +function makeApp({ user = null, respond = () => undefined } = {}) { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: user ? [user] : [] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + const r = respond(sql, params); + if (r) return r; + return { rows: [] }; + }, + }; + return (async () => { + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + return { app, calls }; + })(); +} + +const cookies = { teach_session: 'x'.repeat(64) }; +const parent = { id: 20, username: 'ouder', role: 'parent', school_id: null, class_id: null, data: {}, data_rev: 0 }; +const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const homeChild = { id: 30, username: 'kind', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 }; + +test('ouder kan zich registreren en is direct ingelogd', async () => { + const { app, calls } = await makeApp({ respond: (sql) => { + if (sql.includes("VALUES ($1,'parent'")) return { rows: [{ id: 20, username: 'ouder', role: 'parent', school_id: null }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent', + payload: { username: 'ouder', password: 'geheim123' } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().user.role, 'parent'); + assert.ok(res.cookies.some((c) => c.name === 'teach_session')); + assert.ok(calls.some((c) => c.sql.includes('INSERT INTO sessions'))); + await app.close(); +}); + +test('registratie weigert zwak wachtwoord en dubbele naam', async () => { + const { app } = await makeApp(); + const res = await app.inject({ method: 'POST', url: '/api/auth/register-parent', + payload: { username: 'ouder', password: 'kort' } }); + assert.equal(res.statusCode, 400); + const { app: app2 } = await makeApp({ respond: (sql) => { + if (sql.includes("VALUES ($1,'parent'")) { const e = new Error('dup'); e.code = '23505'; throw e; } + return undefined; + } }); + const res2 = await app2.inject({ method: 'POST', url: '/api/auth/register-parent', + payload: { username: 'ouder', password: 'geheim123' } }); + assert.equal(res2.statusCode, 409); + await app.close(); await app2.close(); +}); + +test('ouder ziet gekoppelde kinderen met school en aanmeldstatus', async () => { + const { app } = await makeApp({ user: parent, respond: (sql) => { + if (sql.includes('FROM parent_children pc JOIN users u')) return { rows: [ + { id: 30, username: 'kind', display_name: 'Kind', school_id: null, class_id: null, school_name: null, class_name: null }, + { id: 31, username: 'zus', display_name: 'Zus', school_id: 2, class_id: 5, school_name: 'De Regenboog', class_name: 'Groep 4' }, + ] }; + if (sql.includes('FROM school_link_requests r JOIN schools s')) return { rows: [ + { pupil_id: 30, school_name: 'De Regenboog' }, + ] }; + return undefined; + } }); + const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); + assert.equal(res.statusCode, 200, res.body); + const kids = res.json().children; + assert.equal(kids.length, 2); + assert.equal(kids[0].appliedTo, 'De Regenboog'); + assert.equal(kids[1].schoolName, 'De Regenboog'); + assert.equal(kids[1].className, 'Groep 4'); + await app.close(); +}); + +test('parent-routes zijn niet voor andere rollen', async () => { + const { app } = await makeApp({ user: admin }); + const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); + assert.equal(res.statusCode, 403); + await app.close(); +}); + +test('ouder maakt een thuiskind aan en krijgt het wachtwoord eenmalig', async () => { + const { app, calls } = await makeApp({ user: parent, respond: (sql) => { + if (sql.includes("VALUES ($1,'pupil',NULL")) return { rows: [{ id: 30, username: 'kind', role: 'pupil', school_id: null }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/parent/children', cookies, + payload: { name: 'kind' } }); + assert.equal(res.statusCode, 200, res.body); + assert.ok(res.json().child.password, 'wachtwoord hoort in de respons'); + const link = calls.find((c) => c.sql.includes('INSERT INTO parent_children')); + assert.deepEqual(link.params, [20, 30]); + await app.close(); +}); + +test('ouder-code koppelt eenmalig en vervalt daarna', async () => { + const { app, calls } = await makeApp({ user: parent, respond: (sql) => { + if (sql.includes('WHERE parent_code = $1')) return { rows: [{ id: 40, username: 'schoolkind', role: 'pupil', school_id: 2 }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/parent/children/link', cookies, + payload: { code: 'k7fp-3rzm' } }); + assert.equal(res.statusCode, 200, res.body); + assert.ok(calls.some((c) => c.sql.includes('INSERT INTO parent_children'))); + const clear = calls.find((c) => c.sql.includes('SET parent_code = NULL')); + assert.deepEqual(clear.params, [40]); + // onbekende code: 404 + const { app: app2 } = await makeApp({ user: parent }); + const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/link', cookies, + payload: { code: 'XXXX-XXXX' } }); + assert.equal(res2.statusCode, 404); + await app.close(); await app2.close(); +}); + +test('aanmelden bij een school maakt een verzoek; dubbel geeft 409', async () => { + const respond = (sql, params) => { + if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; + if (sql.startsWith('SELECT * FROM schools WHERE id')) return { rows: [{ id: 2, name: 'De Regenboog' }] }; + return undefined; + }; + const { app, calls } = await makeApp({ user: parent, respond }); + const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, + payload: { schoolId: 2 } }); + assert.equal(res.statusCode, 200, res.body); + const ins = calls.find((c) => c.sql.includes('INSERT INTO school_link_requests')); + assert.deepEqual(ins.params, [2, 20, 30]); + // zelfde school nogmaals: unieke index -> 409 met nette melding + const { app: app2 } = await makeApp({ user: parent, respond: (sql, params) => { + if (sql.includes('INSERT INTO school_link_requests')) { const e = new Error('dup'); e.code = '23505'; throw e; } + return respond(sql, params); + } }); + const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, + payload: { schoolId: 2 } }); + assert.equal(res2.statusCode, 409); + assert.match(res2.json().error, /al aangemeld/); + await app.close(); await app2.close(); +}); + +test('aanmelden kan niet voor een kind dat al op een school zit of niet gekoppeld is', async () => { + const { app } = await makeApp({ user: parent, respond: (sql) => { + if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ ...homeChild, school_id: 2 }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, + payload: { schoolId: 2 } }); + assert.equal(res.statusCode, 400); + const { app: app2 } = await makeApp({ user: parent }); // geen koppeling + const res2 = await app2.inject({ method: 'POST', url: '/api/parent/children/30/apply', cookies, + payload: { schoolId: 2 } }); + assert.equal(res2.statusCode, 403); + await app.close(); await app2.close(); +}); + +test('staf genereert een ouder-code voor een leerling van de eigen school', async () => { + const { app, calls } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 40, role: 'pupil', school_id: 2, class_id: 5 }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.match(res.json().parentCode, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/); + assert.ok(calls.some((c) => c.sql.includes('SET parent_code = $1'))); + await app.close(); +}); + +test('ouders zelf kunnen geen ouder-code genereren', async () => { + const { app } = await makeApp({ user: parent }); + const res = await app.inject({ method: 'POST', url: '/api/admin/users/40/parent-code', cookies }); + assert.equal(res.statusCode, 403); + await app.close(); +}); + +test('school ziet openstaande aanmeldingen met kind- en oudernaam', async () => { + const { app } = await makeApp({ user: admin, respond: (sql) => { + if (sql.includes('FROM school_link_requests r')) return { rows: [ + { id: 1, created_at: 'nu', pupil_id: 30, pupil_username: 'kind', pupil_name: 'Kind', parent_username: 'ouder', parent_name: 'Ouder' }, + ] }; + return undefined; + } }); + const res = await app.inject({ method: 'GET', url: '/api/admin/link-requests', cookies }); + assert.equal(res.statusCode, 200, res.body); + const r = res.json().requests[0]; + assert.equal(r.pupil.displayName, 'Kind'); + assert.equal(r.parent.displayName, 'Ouder'); + await app.close(); +}); + +const request = { id: 1, school_id: 2, parent_id: 20, pupil_id: 30 }; + +test('adopteren zet school en klas op het thuisaccount en ruimt verzoeken op', async () => { + const { app, calls } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 5, school_id: 2, name: 'Groep 4' }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'adopt', classId: 5 } }); + assert.equal(res.statusCode, 200, res.body); + const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id')); + assert.deepEqual(upd.params, [2, 5, null, 30]); + assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id'))); + await app.close(); +}); + +test('adopteren weigert een klas van een andere school en meldt naamconflict', async () => { + const { app } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 9, school_id: 3, name: 'Elders' }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'adopt', classId: 9 } }); + assert.equal(res.statusCode, 400); + const { app: app2 } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + if (sql.includes('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; } + return undefined; + } }); + const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'adopt' } }); + assert.equal(res2.statusCode, 409); + assert.match(res2.json().error, /bestaat al/); + await app.close(); await app2.close(); +}); + +test('koppelen aan bestaande leerling verhuist de ouderkoppeling; leeg thuisaccount wordt opgeruimd', async () => { + const { app, calls } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] }; + if (sql.includes('AS empty FROM users')) return { rows: [{ empty: true }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'match', pupilId: 41 } }); + assert.equal(res.statusCode, 200, res.body); + const move = calls.find((c) => c.sql.includes('INSERT INTO parent_children')); + assert.deepEqual(move.params, [41, 30]); + assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id'))); + await app.close(); +}); + +test('thuisaccount met borddata blijft bestaan na koppelen', async () => { + const { app, calls } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 41, role: 'pupil', school_id: 2 }] }; + if (sql.includes('AS empty FROM users')) return { rows: [{ empty: false }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'match', pupilId: 41 } }); + assert.equal(res.statusCode, 200, res.body); + assert.ok(!calls.some((c) => c.sql.startsWith('DELETE FROM users WHERE id'))); + assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id'))); + await app.close(); +}); + +test('afwijzen verwijdert alleen het verzoek; andere school mag niet afhandelen', async () => { + const { app, calls } = await makeApp({ user: admin, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'reject' } }); + assert.equal(res.statusCode, 200, res.body); + assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE id'))); + const { app: app2 } = await makeApp({ user: { ...admin, school_id: 9 }, respond: (sql) => { + if (sql.startsWith('SELECT * FROM school_link_requests')) return { rows: [request] }; + return undefined; + } }); + const res2 = await app2.inject({ method: 'POST', url: '/api/admin/link-requests/1/resolve', cookies, + payload: { action: 'reject' } }); + assert.equal(res2.statusCode, 403); + await app.close(); await app2.close(); +}); + +test('thuiskind logt voortgang met de gekoppelde ouder als leerkracht', async () => { + const { app, calls } = await makeApp({ user: homeChild, respond: (sql) => { + if (sql.includes('FROM parent_children WHERE pupil_id')) return { rows: [{ parent_id: 20 }] }; + return undefined; + } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, + payload: { widgetId: 'w1', widgetType: 'math', attempts: 5, correct: 4, stars: 2 } }); + assert.equal(res.statusCode, 200, res.body); + const ins = calls.find((c) => c.sql.includes('INSERT INTO progress_events')); + assert.equal(ins.params[1], 20); + assert.equal(ins.params[2], 'thuis'); + await app.close(); +}); + +test('gekoppelde ouder ziet de voortgang van het kind, een vreemde ouder niet', async () => { + const { app } = await makeApp({ user: parent, respond: (sql) => { + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; + if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; + if (sql.startsWith('SELECT * FROM progress_events')) return { rows: [] }; + return undefined; + } }); + const res = await app.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies }); + assert.equal(res.statusCode, 200, res.body); + const { app: app2 } = await makeApp({ user: parent, respond: (sql) => { + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; + return undefined; + } }); + const res2 = await app2.inject({ method: 'GET', url: '/api/progress/pupil/30', cookies }); + assert.equal(res2.statusCode, 403); + await app2.close(); await app.close(); +});