Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
5 changed files with 608 additions and 4 deletions
Showing only changes of commit 9d83e13bc3 - Show all commits

View file

@ -1 +1 @@
0.3.56-beta
0.3.57-beta

30
db/013_parent_portal.sql Normal file
View file

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

View file

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

View file

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

321
test/parent-portal.test.js Normal file
View file

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