feat: server-kant ouderportaal (v0.3.57-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
This commit is contained in:
parent
a9bf2270d0
commit
9d83e13bc3
5 changed files with 608 additions and 4 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.56-beta
|
||||
0.3.57-beta
|
||||
|
|
|
|||
30
db/013_parent_portal.sql
Normal file
30
db/013_parent_portal.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
257
src/api.js
257
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).
|
||||
|
|
|
|||
321
test/parent-portal.test.js
Normal file
321
test/parent-portal.test.js
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue