teach/src/api.js
Ramon 043831e1dd
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s
feat: make assignments independent from boards
2026-07-25 22:15:52 +02:00

1894 lines
104 KiB
JavaScript

// teach API: inloggen, rollen en gebruikersbeheer.
//
// Rollen en rechten:
// super - systeemmanager: alles, over alle scholen heen,
// kan andere systeemmanagers aanwijzen.
// admin - schoolbeheerder: alle gebruikers en klassen van de eigen school.
// teacher - groepsleiding: leerlingen aanmaken in eigen klas, leerlingen
// verplaatsen naar andere klassen (uitwisselen), leerling-
// wachtwoorden inzien en aanpassen.
// pupil - leerling: inloggen en eigen borden; kan het eigen wachtwoord
// NIET inzien of wijzigen.
// parent - ouder (school_id NULL): eigen registratie, beheert gekoppelde
// kind-accounts en ziet hun voortgang; geen beheer, geen borden.
import {
hashPassword, verifyPassword, createSession, userFromRequest,
newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole,
} from './auth.js';
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
import registerLiveSessions from './live-classroom.js';
export default async function api(app) {
const pool = app.pg;
// Voorkomt meetbaar snellere antwoorden voor niet-bestaande gebruikers.
const dummyPasswordHash = await hashPassword(newLinkCode());
const cookieSecure = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production';
const cookieOptions = { path: '/', httpOnly: true, secure: cookieSecure, sameSite: 'strict' };
const setSessionCookie = (reply, token, role) => reply.setCookie(SESSION_COOKIE, token, {
...cookieOptions, maxAge: sessionDaysForRole(role) * 24 * 60 * 60,
});
const clearSessionCookie = (reply) => reply.clearCookie(SESSION_COOKIE, { path: '/' });
// ---- helpers --------------------------------------------------------------
const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; };
app.decorateRequest('user', null);
app.addHook('preHandler', async (req, reply) => {
if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) {
const expectedOrigin = `${req.protocol}://${req.headers.host}`;
if (req.headers.origin !== expectedOrigin) return fail(reply, 403, 'ongeldige origin');
}
req.user = await userFromRequest(pool, req);
});
app.addHook('onResponse', async (req, reply) => {
if ([401, 403, 429].includes(reply.statusCode)) {
req.log.warn({ securityEvent: true, status: reply.statusCode, method: req.method,
url: req.url, ip: req.ip, userId: req.user?.id ?? null }, 'security-relevant response');
}
});
const need = (req, reply, roles) => {
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); }
if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); }
};
// groepsleiding-only regels gelden alleen als iemand *uitsluitend* teacher is;
// wie er (via een extra rol) ook admin of super bij heeft, valt onder de
// ruimere regels van die rol i.p.v. de leerling-only-beperking van teacher.
const teacherOnly = (req) =>
req.user.allRoles.includes('teacher')
&& !req.user.allRoles.includes('admin')
&& !req.user.allRoles.includes('super');
// welke rollen iemand mag aanmaken: unie over al hun rollen (hoofd + extra)
const creatableRoles = (req) =>
[...new Set(req.user.allRoles.flatMap((r) => CREATABLE_ROLES[r] || []))];
const sameSchool = (req, u) =>
req.user.allRoles.includes('super') || Number(u.school_id) === Number(req.user.school_id);
app.setErrorHandler((err, req, reply) => {
if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message });
req.log.error({ err }, 'onverwachte serverfout');
// Altijd de echte oorzaak meegeven i.p.v. een kale "serverfout": dit is een
// intern beheertool (geen publieke site), en een onherleidbare "serverfout"
// was al twee keer een doodlopende weg bij het diagnosticeren van een bug -
// eerder was dit alleen voor ingelogde gebruikers (req.user), maar bleek
// ook dát soms nog niet gezet als de fout vóór/tijdens het inloggen zit.
reply.code(500).send({ error: `serverfout: ${err.message}` });
});
// ---- version --------------------------------------------------------------
app.get('/version', async () => ({
name: 'teach',
version: process.env.APP_VERSION ?? 'dev',
sha: process.env.GIT_SHA || undefined,
}));
// ---- publiek: scholenlijst voor het inlogscherm ----------------------------
app.get('/schools', async () => {
const r = await pool.query('SELECT id, name FROM schools ORDER BY name');
return r.rows.map((s) => ({ id: Number(s.id), name: s.name }));
});
// ---- site-inhoud (CMS van de systeemmanager) --------------------------------
// Kleine sleutel/waarde-opslag: welkomstwoord (inlogscherm), update-melding
// (banner voor alle gebruikers) en aanvullende widget-uitleg (galerij).
// Lezen is publiek (het inlogscherm toont het welkomstwoord al vóór het
// inloggen); schrijven kan alleen de systeemmanager, per toegestane sleutel
// en met een maat-limiet zodat dit nooit een opslagplaats voor blobs wordt.
const CONTENT_KEYS = { welcome: 8 * 1024, announcement: 8 * 1024, widgetInfo: 64 * 1024 };
app.get('/content', async () => {
const r = await pool.query('SELECT key, value FROM site_content');
const out = {};
r.rows.forEach((row) => { out[row.key] = row.value; });
return out;
});
app.put('/admin/content/:key', async (req, reply) => {
need(req, reply, PERMISSIONS['content.manage']);
const key = req.params.key;
if (!(key in CONTENT_KEYS)) return fail(reply, 404, 'onbekende inhoud');
const value = req.body ?? {};
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(reply, 400, 'ongeldige inhoud');
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > CONTENT_KEYS[key]) return fail(reply, 413, 'inhoud te groot');
await pool.query(
`INSERT INTO site_content (key, value, updated_at, updated_by) VALUES ($1, $2, now(), $3)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now(), updated_by = $3`,
[key, value, req.user.id]);
return { ok: true };
});
// ---- auth -------------------------------------------------------------------
app.post('/auth/login', {
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
}, async (req, reply) => {
const { schoolId, username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string'
|| !username.trim() || username.length > 30 || password.length > 128)
return fail(reply, 400, 'ongeldige naam of wachtwoord');
const r = schoolId
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username.trim()])
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username.trim()]);
const u = r.rows[0];
const passwordOk = await verifyPassword(password, u?.password_hash || dummyPasswordHash);
if (!u || !passwordOk) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
// account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk
await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = await createSession(pool, u.id, u.role);
setSessionCookie(reply, token, u.role);
return { user: publicUser(u) };
});
// Account activeren met een koppelcode (groepsleiding/beheerders)
app.post('/auth/link', {
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
}, async (req, reply) => {
const { code, password } = req.body ?? {};
if (typeof code !== 'string' || code.length > 20 || typeof password !== 'string' || password.length > 128)
return fail(reply, 400, 'ongeldige koppelcode of wachtwoord');
const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
const err = checkPassword(password, u.role);
if (err) return fail(reply, 400, err);
const hash = await hashPassword(password);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]);
const token = await createSession(pool, u.id, u.role);
setSessionCookie(reply, token, u.role);
return { user: publicUser({ ...u, password_hash: hash, link_code: null }) };
});
app.post('/auth/logout', async (req, reply) => {
const token = sessionTokenFromRequest(req);
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [hashSessionToken(token)]);
clearSessionCookie(reply);
return { ok: true };
});
app.get('/auth/me', async (req, reply) => {
if (!req.user) return fail(reply, 401, 'niet ingelogd');
return { user: publicUser(req.user) };
});
// Eigen wachtwoord wijzigen - niet voor leerlingen
app.patch('/auth/password', {
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (req, reply) => {
need(req, reply, PERMISSIONS['password.own.change']);
const { currentPassword, password } = req.body ?? {};
if (typeof currentPassword !== 'string' || typeof password !== 'string') return fail(reply, 400, 'ongeldig wachtwoord');
if (!(await verifyPassword(currentPassword, req.user.password_hash))) return fail(reply, 401, 'huidig wachtwoord onjuist');
const err = checkPassword(password, req.user.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
[await hashPassword(password), req.user.id]);
const token = sessionTokenFromRequest(req);
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND token <> $2',
[req.user.id, token ? hashSessionToken(token) : '']);
return { ok: true };
});
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
app.get('/me/data', async (req, reply) => {
need(req, reply);
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
});
// ---- eigen personage (avatar) -----------------------------------------------
// Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven,
// geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij.
const AVATAR_SPECIES = ['dino', 'monster', 'animal'];
const AVATAR_VARIANTS = {
dino: ['rex', 'triceratops', 'stegosaurus', 'raptor'],
monster: ['horned', 'cyclops', 'slime', 'alien'],
animal: ['cat', 'dog', 'rabbit', 'bear'],
};
const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk'];
const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat', 'star'];
const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings', 'medal'];
const AVATAR_REWARD_CATALOG = [
{ key: 'head:star', type: 'head', item: 'star', points: 1 },
{ key: 'body:medal', type: 'body', item: 'medal', points: 3 },
{ key: 'dance:robot', type: 'dance', item: 'robot', points: 6 },
];
const avatarRewardKey = (type, item) =>
AVATAR_REWARD_CATALOG.find((reward) => reward.type === type && reward.item === item)?.key || null;
const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
app.get('/me/avatar-rewards', async (req, reply) => {
need(req, reply);
const points = Number((await pool.query(
'SELECT reward_points FROM users WHERE id = $1', [req.user.id])).rows[0]?.reward_points || 0);
const rewards = (await pool.query(
'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 ORDER BY unlocked_at',
[req.user.id])).rows.map((row) => row.reward_key);
return { points, rewards, catalog: AVATAR_REWARD_CATALOG };
});
app.put('/me/avatar', async (req, reply) => {
need(req, reply);
const b = req.body ?? {};
if (!AVATAR_SPECIES.includes(b.species)) return fail(reply, 400, 'ongeldige soort');
const variantOptions = AVATAR_VARIANTS[b.species];
const speciesVariant = b.speciesVariant ?? variantOptions[0];
if (!variantOptions.includes(speciesVariant)) return fail(reply, 400, 'ongeldige soortvariant');
if (!isHexColor(b.bodyColor)) return fail(reply, 400, 'ongeldige lichaamskleur');
if (!AVATAR_HAIR.includes(b.hairStyle)) return fail(reply, 400, 'ongeldige haarstijl');
if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur');
if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire');
if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire');
const requiredRewards = [
avatarRewardKey('head', b.accessoryHead),
avatarRewardKey('body', b.accessoryBody),
].filter(Boolean);
if (requiredRewards.length) {
const owned = (await pool.query(
'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 AND reward_key = ANY($2::text[])',
[req.user.id, requiredRewards])).rows.map((row) => row.reward_key);
if (requiredRewards.some((key) => !owned.includes(key))) return fail(reply, 403, 'avatarbeloning nog niet vrijgespeeld');
}
const avatar = {
species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody,
};
await pool.query('UPDATE users SET avatar_data = $1 WHERE id = $2', [avatar, req.user.id]);
return { avatar };
});
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
// (oude clients blijven werken, laatste schrijver wint dan zoals voorheen).
const revFromRequest = (req) => {
const h = req.headers['x-data-rev'];
if (h === undefined) return null;
const n = Number(h);
return Number.isInteger(n) && n >= 0 ? n : null;
};
// Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar
// hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en
// maximaal 10 versies per gebruiker.
const SNAPSHOT_MIN_AGE = "10 minutes";
const SNAPSHOT_KEEP = 10;
const snapshotUserData = async (userId) => {
const fresh = (await pool.query(
`SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`,
[userId, SNAPSHOT_MIN_AGE])).rows[0];
if (fresh) return;
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[userId]);
await pool.query(
`DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN (
SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
)`, [userId, SNAPSHOT_KEEP]);
};
const writeUserData = async (req, reply, data) => {
await snapshotUserData(req.user.id);
const rev = revFromRequest(req);
const r = rev === null
? await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
[data, req.user.id])
: await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev',
[data, req.user.id, rev]);
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
return { ok: true, rev: Number(r.rows[0].data_rev) };
};
// schoolleerlingen hebben geen eigen borden-data; thuiskinderen (pupil
// zonder school, ouderportaal) wél - die mogen ook hun reservekopieën zien
const schoolPupil = (req) => req.user.role === 'pupil' && req.user.school_id != null;
app.get('/me/data/versions', async (req, reply) => {
need(req, reply);
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const rows = (await pool.query(
'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id])).rows;
return { versions: rows.map((v) => ({ id: Number(v.id), createdAt: v.created_at })) };
});
app.post('/me/data/restore', async (req, reply) => {
need(req, reply);
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const { versionId } = req.body ?? {};
if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie');
const v = (await pool.query(
'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2',
[versionId, req.user.id])).rows[0];
if (!v) return fail(reply, 404, 'versie onbekend');
// de huidige data eerst zelf als versie bewaren, zodat een herstel ook
// weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel)
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[req.user.id]);
const r = await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
[v.data, req.user.id]);
return { data: v.data, rev: Number(r.rows[0].data_rev) };
});
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply);
const data = req.body ?? {};
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
return writeUserData(req, reply, data);
});
// Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set)
// i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de
// volledige PUT hierboven, die blijft bestaan voor restore/eerste save.
const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2, anchors: 5 }; // cap in MB
app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply);
const section = req.params.section;
if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie');
const part = req.body ?? {};
// anchors is een lijst, de overige secties zijn objecten
if (!part || typeof part !== 'object') return fail(reply, 400, 'ongeldige data');
if (Array.isArray(part) !== (section === 'anchors')) return fail(reply, 400, 'ongeldige data');
if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024)
return fail(reply, 413, 'data te groot');
await snapshotUserData(req.user.id);
const rev = revFromRequest(req);
const set = `data = jsonb_set(coalesce(data, '{}'::jsonb), $1, $2::jsonb), data_rev = data_rev + 1, data_updated_at = now()`;
const r = rev === null
? await pool.query(`UPDATE users SET ${set} WHERE id = $3 RETURNING data_rev`,
[`{${section}}`, JSON.stringify(part), req.user.id])
: await pool.query(`UPDATE users SET ${set} WHERE id = $3 AND data_rev = $4 RETURNING data_rev`,
[`{${section}}`, JSON.stringify(part), req.user.id, rev]);
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
return { ok: true, rev: Number(r.rows[0].data_rev) };
});
// ---- scholen (super) ---------------------------------------------------------
app.post('/admin/schools', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {};
if (!name || typeof name !== 'string' || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.patch('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'school onbekend');
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.delete('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
return { ok: true };
});
// Multireset: alle leerlingwachtwoorden van een hele school in één keer
// vernieuwen. Alleen schoolbeheerder (eigen school) en systeemmanager.
app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.resetPasswords']);
const schoolId = req.params.id;
if (!req.user.allRoles.includes('super') && Number(schoolId) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query(
"SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows;
const users = [];
for (const p of pupils) {
const pw = pupilPassword();
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(pw), p.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
}
return { users };
});
// ---- klassen -----------------------------------------------------------------
app.get('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.view']);
const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id;
if (!schoolId) return { classes: [] };
const r = await pool.query(
`SELECT c.id, c.name,
COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids
FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]);
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
});
// een klas naar een ANDERE school verplaatsen - alleen systeemmanager.
// Een klas hoort altijd bij een school (classes.school_id is NOT NULL);
// losmaken kan dus niet - er moet een geldige doelschool zijn.
app.patch('/admin/classes/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
const raw = req.body?.schoolId;
if (raw == null) return fail(reply, 400, 'school verplicht'); // een klas hoort altijd bij een school
const target = Number(raw);
if (!Number.isInteger(target) || target <= 0) return fail(reply, 400, 'ongeldige school');
const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0];
if (!school) return fail(reply, 404, 'school onbekend');
await pool.query('UPDATE classes SET school_id = $1 WHERE id = $2', [target, c.id]);
return { ok: true };
});
app.post('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const { name, school } = req.body ?? {};
const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id;
if (!name || typeof name !== 'string' || name.trim().length > 80 || !schoolId) return fail(reply, 400, 'ongeldige naam of school');
try {
const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]);
return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'klasnaam bestaat al binnen deze school');
throw e;
}
});
app.delete('/admin/classes/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
// Zelfde reden als bij het verwijderen van een gebruiker: alles in één
// try/catch, zodat een mislukking de echte oorzaak teruggeeft i.p.v. terug
// te vallen op de kale "serverfout" van de generieke handler.
try {
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'klas onbekend');
if (!req.user.allRoles.includes('super') && Number(r.rows[0].school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]);
return { ok: true };
} catch (e) {
req.log.error({ err: e }, 'verwijderen klas mislukt');
return fail(reply, 409, `verwijderen mislukt: ${e.detail || e.message}`);
}
});
// groepsleiding aan klas koppelen/loskoppelen
// Elke staf-rol (groepsleiding, schoolbeheerder, systeemmanager) kan aan
// een klas gekoppeld worden; alleen leerlingen niet (die horen al bij één
// klas via users.class_id). Een systeemmanager is school-onafhankelijk en
// mag dus aan klassen van elke school gekoppeld worden.
app.post('/admin/classes/:id/teachers', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const { userId, remove } = req.body ?? {};
if (!userId) return fail(reply, 400, 'gebruiker verplicht');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [userId])).rows[0];
if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend');
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
if (!['teacher', 'admin', 'super'].includes(u.role)) return fail(reply, 400, 'alleen staf kan aan een klas gekoppeld worden');
if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id))
return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen');
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]);
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]);
return { ok: true };
});
// Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen.
// Alleen schoolbeheerder (eigen school) en systeemmanager - niet
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
app.post('/admin/classes/:id/reset-passwords', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
const c = r.rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query(
"SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows;
const users = [];
for (const p of pupils) {
const pw = pupilPassword();
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(pw), p.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
}
return { users };
});
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
app.get('/admin/genpw', async (req, reply) => {
need(req, reply, PERMISSIONS['users.genpw']);
const role = req.query.role === 'pupil' ? 'pupil' : 'staff';
return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
});
// ---- gebruikers ---------------------------------------------------------------
// Lijst: super ziet alles (optioneel per school), admin de eigen school,
// teacher de eigen school maar leerlingen alleen uit de eigen klas(sen).
app.get('/admin/users', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
// Ouders hebben school_id NULL en vallen daardoor buiten elke schoollijst;
// een school ziet wél de ouders die aan haar eigen leerlingen gekoppeld zijn.
// Postgres eist bij SELECT DISTINCT dat elke ORDER BY-expressie letterlijk
// in de select-lijst staat - lower(p.username) alleen via p.* volstond niet
// (gaf "for SELECT DISTINCT, ORDER BY expressions must appear in select
// list"), dus expliciet als extra kolom toevoegen.
const linkedParents = async (schoolId, extra = '', params = []) => (await pool.query(
`SELECT DISTINCT p.*, lower(p.username) AS sort_key FROM users p
JOIN parent_children pc ON pc.parent_id = p.id
JOIN users k ON k.id = pc.pupil_id
WHERE k.school_id = $1 ${extra} ORDER BY sort_key`,
[schoolId, ...params])).rows;
let rows;
if (req.user.allRoles.includes('super')) {
rows = (req.query.school
? await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.query.school])
: await pool.query('SELECT * FROM users ORDER BY school_id NULLS FIRST, role, lower(username)')).rows;
if (req.query.school) rows = rows.concat(await linkedParents(req.query.school));
} else if (teacherOnly(req)) {
rows = (await pool.query(
`SELECT * FROM users
WHERE school_id = $1
AND (role != 'pupil' OR class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2))
ORDER BY role, lower(username)`,
[req.user.school_id, req.user.id])).rows;
rows = rows.concat(await linkedParents(req.user.school_id,
'AND k.class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2)', [req.user.id]));
} else {
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows;
rows = rows.concat(await linkedParents(req.user.school_id));
}
// extra stafrollen per gebruiker erbij ophalen (in één keer, niet per rij)
const roleRows = rows.length
? (await pool.query('SELECT user_id, role FROM user_roles WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
: [];
const extraByUser = new Map();
roleRows.forEach((r) => {
const key = String(r.user_id);
extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]);
});
// klas-koppelingen van groepsleiding (class_teachers), ook in één keer
const ctRows = rows.length
? (await pool.query('SELECT user_id, class_id FROM class_teachers WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
: [];
const classesByUser = new Map();
ctRows.forEach((r) => {
const key = String(r.user_id);
classesByUser.set(key, [...(classesByUser.get(key) || []), Number(r.class_id)]);
});
// kindernamen bij ouder-rijen, zodat de school ziet wie bij wie hoort
const parentIds = rows.filter((u) => u.role === 'parent').map((u) => u.id);
const kidRows = parentIds.length
? (await pool.query(
`SELECT pc.parent_id, k.display_name, k.username FROM parent_children pc
JOIN users k ON k.id = pc.pupil_id WHERE pc.parent_id = ANY($1)`, [parentIds])).rows
: [];
const kidsByParent = new Map();
kidRows.forEach((r) => {
const key = String(r.parent_id);
kidsByParent.set(key, [...(kidsByParent.get(key) || []), r.display_name || r.username]);
});
return { users: rows.map((u) => ({
...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }),
linkCode: u.link_code || null,
everLoggedIn: !!u.last_login_at,
classIds: classesByUser.get(String(u.id)) || [],
...(u.role === 'parent' ? { childNames: kidsByParent.get(String(u.id)) || [] } : {}),
})) };
});
// Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school.
// teacher: alleen leerlingen in de eigen school.
app.post('/admin/users', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const b = req.body ?? {};
const role = b.role;
if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
// Extra veiligheidsstap voor de meest ingrijpende actie in dit beheer: een
// nieuwe systeemmanager aanmaken vereist het eigen wachtwoord van de
// aanmaker, ter bevestiging - los van (en aanvullend op) creatableRoles(),
// die al bepaalt dát alleen een systeemmanager dit mag proberen.
if (role === 'super') {
const ok = await verifyPassword(b.confirmPassword, req.user.password_hash);
if (!ok) return fail(reply, 403, 'eigen wachtwoord onjuist');
}
const schoolId = req.user.allRoles.includes('super') ? (role === 'super' ? null : b.school) : req.user.school_id;
if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
const username = (b.username || '').trim();
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
try {
if (role === 'pupil') {
if (b.classId != null) {
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
if (!c || Number(c.school_id) !== Number(schoolId)) return fail(reply, 400, 'klas hoort niet bij deze school');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
}
const pw = b.password || pupilPassword();
const err = checkPassword(pw, 'pupil');
if (err) return fail(reply, 400, err);
const r = await pool.query(
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash)
VALUES ($1,'pupil',$2,$3,$4,$5) RETURNING *`,
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw)]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
// groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of
// gegenereerd), ofwel de klassieke koppelcode om zelf te activeren.
if (b.password || b.generatePassword) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, role);
if (err) return fail(reply, 400, err);
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, password_hash)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, await hashPassword(pw)]);
return { user: { ...publicUser(r.rows[0]), password: pw } };
}
const code = newLinkCode();
const r = await pool.query(
`INSERT INTO users (username, role, school_id, display_name, link_code)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[username, role, schoolId, b.displayName || username, code]);
return { user: { ...publicUser(r.rows[0]), linkCode: code } };
} catch (e) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
});
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
app.patch('/admin/users/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen');
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
const b = req.body ?? {};
if (b.displayName) {
if (typeof b.displayName !== 'string' || b.displayName.trim().length > 80) return fail(reply, 400, 'ongeldige weergavenaam');
await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName.trim(), u.id]);
}
if (b.classId !== undefined && u.role === 'pupil') {
if (b.classId !== null) {
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
if (!c || Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
}
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
}
if (b.password && u.role === 'pupil') {
const err = checkPassword(b.password, 'pupil');
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
[await hashPassword(b.password), u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
}
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
const pw = b.password || staffPassword();
const err = checkPassword(pw, u.role);
if (err) return fail(reply, 400, err);
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
return { ok: true, password: pw };
}
if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
const code = newLinkCode();
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
return { ok: true, linkCode: code };
}
// school koppelen/losmaken (alleen systeemmanager): losse accounts kunnen
// teruggezet worden naar een school en bewust losgemaakt. Super en parent
// zijn per definitie schoolloos - daarvoor is dit een stille no-op.
if (b.schoolId !== undefined && req.user.allRoles.includes('super')
&& ['teacher', 'admin', 'pupil'].includes(u.role)) {
const target = b.schoolId === null ? null : Number(b.schoolId);
if (target !== null) {
const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0];
if (!school) return fail(reply, 404, 'school onbekend');
}
if (target !== (u.school_id == null ? null : Number(u.school_id))) {
// klasindeling behouden als de huidige klas al bij de doelschool hoort
// (herstelvolgorde: eerst de klas terugkoppelen, dan de leerlingen)
let keepClass = null;
if (u.class_id != null && target !== null) {
const cls = (await pool.query('SELECT school_id FROM classes WHERE id = $1', [u.class_id])).rows[0];
if (cls && Number(cls.school_id) === target) keepClass = u.class_id;
}
try {
await pool.query('UPDATE users SET school_id = $1, class_id = $2 WHERE id = $3', [target, keepClass, u.id]);
} catch (e) {
// naam kan botsen in de doelschool óf (bij losmaken) in de
// school-loze naamruimte (systeemmanagers/ouders/thuiskinderen)
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
throw e;
}
await pool.query('DELETE FROM class_teachers WHERE user_id = $1', [u.id]);
// een oude individuele toewijzing verwijst naar een bord van de vorige school
if (u.role === 'pupil') await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
}
}
// rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven
// leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren.
// 'super' is hier bewust GEEN geldig doel: een systeemmanager aanmaken kan
// alleen via POST /admin/users, met het eigen-wachtwoord-bevestigingsstap -
// niet door een bestaande schoolbeheerder/groepsleiding via deze
// eenvoudige rolwijziging te promoveren. Demoteren van een bestaande
// systeemmanager naar admin/teacher blijft wel mogelijk.
if (b.role && can(req.user.allRoles, 'users.role.change')
&& ['admin', 'teacher'].includes(b.role)
&& ['super', 'admin', 'teacher'].includes(u.role))
await pool.query('UPDATE users SET role = $1 WHERE id = $2', [b.role, u.id]);
// extra functies naast de hoofdrol (bv. een teacher die ook admin-rechten krijgt) -
// alleen voor staf, zelfde recht als hoofdrol-wijziging; hoofdrol zelf telt niet
// ook nog als "extra". Ook hier is 'super' bewust geen toegestane extra rol,
// om dezelfde reden als hierboven.
if (b.extraRoles !== undefined && can(req.user.allRoles, 'users.role.change') && u.role !== 'pupil') {
const extra = [...new Set(b.extraRoles)].filter((r) => ['admin', 'teacher'].includes(r) && r !== u.role);
await pool.query('DELETE FROM user_roles WHERE user_id = $1', [u.id]);
for (const role of extra) {
await pool.query('INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING', [u.id, role]);
}
return { ok: true, extraRoles: extra };
}
return { ok: true };
});
// Een gebruiker en al z'n afhankelijke gegevens in één transactie verwijderen.
// We rekenen NIET op ON DELETE CASCADE: sommige (oudere) databases hebben een
// foreign key die dat mist, waardoor DELETE FROM users een 500 "serverfout"
// geeft. Door de afhankelijke rijen eerst zelf te verwijderen werkt het
// ongeacht de exacte FK-instelling. Migratie 015 herstelt de FK's daarnaast
// structureel; dit is het vangnet dat sowieso werkt.
const deleteUserFully = async (userId) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
// bewuste SET NULL: wie iets in het CMS wijzigde blijft die inhoud houden
await client.query('UPDATE site_content SET updated_by = NULL WHERE updated_by = $1', [userId]);
// eerst de klas/school van de gebruiker zelf lossnijden zodat triggers op
// eventuele resten niet in de weg zitten
const dependents = [
'DELETE FROM sessions WHERE user_id = $1',
'DELETE FROM user_roles WHERE user_id = $1',
'DELETE FROM class_teachers WHERE user_id = $1',
'DELETE FROM assignments WHERE pupil_id = $1 OR teacher_id = $1',
'DELETE FROM progress_events WHERE pupil_id = $1 OR teacher_id = $1',
'DELETE FROM parent_children WHERE parent_id = $1 OR pupil_id = $1',
'DELETE FROM school_link_requests WHERE parent_id = $1 OR pupil_id = $1',
'DELETE FROM user_data_versions WHERE user_id = $1',
'DELETE FROM shared_library WHERE owner_id = $1',
'DELETE FROM image_assets WHERE owner_id = $1',
'DELETE FROM image_folders WHERE owner_id = $1',
'DELETE FROM image_themes WHERE owner_id = $1',
'DELETE FROM boards WHERE owner_id = $1',
'DELETE FROM folders WHERE owner_id = $1',
];
for (const sql of dependents) await client.query(sql, [userId]);
await client.query('DELETE FROM users WHERE id = $1', [userId]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
};
app.delete('/admin/users/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['users.manage']);
// Alles vanaf hier (ook de opzoek- en rechtencontroles, niet alleen de
// verwijdering zelf) in één try/catch: anders valt een fout die vóór
// deleteUserFully() optreedt (bv. een kapotte query) terug op de kale
// "serverfout" van de generieke handler i.p.v. een zichtbare reden.
try {
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
const u = r.rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen');
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
await deleteUserFully(u.id);
return { ok: true };
} catch (e) {
// Zou niet meer moeten voorkomen (alle afhankelijke rijen worden in
// deleteUserFully al weggehaald), maar als het tóch faalt geven we de
// echte reden mee i.p.v. een kale "serverfout", zodat de oorzaak zichtbaar is.
req.log.error({ err: e }, 'verwijderen gebruiker mislukt');
return fail(reply, 409, `verwijderen mislukt: ${e.detail || e.message}`);
}
});
// ---- toewijzingen (leerling-omgeving) ------------------------------------------
// Een leerkracht koppelt een eigen bord (via het stabiele board-id) aan een klas
// (standaard voor iedereen erin) of een individuele leerling (uitzondering, voor
// wie op een ander niveau werkt). Leerling-eigen koppeling wint van de klas-
// koppeling. Geen momentopname: het bord wordt live opgezocht in de data van de
// leerkracht, dus nieuwe woorden die de leerkracht toevoegt komen vanzelf door.
const classOwnedByTeacher = async (classId, userId) =>
!!(await pool.query('SELECT 1 FROM class_teachers WHERE class_id = $1 AND user_id = $2', [classId, userId])).rows[0];
const findBoardById = (data, boardId) => {
const folders = data?.boards?.folders || [];
for (const fo of folders) {
const bo = (fo.boards || []).find((b) => b.id === boardId);
if (bo) return bo;
}
return null;
};
const describeAssignment = async (a) => {
const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
const board = t ? findBoardById(t.data, a.board_id) : null;
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
const sequence = Array.isArray(a.sequence) ? a.sequence.map((step) => {
if (step.source === 'library' && step.widgetType) {
return {
...step,
boardName: null,
themeName: step.theme?.name || null,
};
}
const stepBoard = t ? findBoardById(t.data, step.boardId) : null;
const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId);
return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null };
}) : [];
return {
teacherId: Number(a.teacher_id),
teacherName: t ? (t.display_name || t.username) : null,
boardId: a.board_id,
boardName: board ? board.name : null,
widgetId: a.widget_id || null,
widgetType: widget ? widget.id : null,
mode: a.mode || 'werken',
title: a.title || '',
sequence,
updatedAt: a.updated_at,
};
};
// Gedeelde validatie voor beide PUT-routes: heel bord (widgetId null, bestaand
// gedrag) of één widget van dat bord, met modus kijken/werken. De widget moet
// echt in het opgegeven bord van de leerkracht zelf staan.
const parseShareBody = (req, reply) => {
const { boardId, widgetId, mode } = req.body ?? {};
if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId');
const board = findBoardById(req.user.data, boardId);
if (!board) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker');
const m = mode ?? 'werken';
if (m !== 'kijken' && m !== 'werken') return fail(reply, 400, 'ongeldige modus');
if (widgetId != null) {
if (typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
if (!(board.data?.widgets || []).some((w) => w.wid === widgetId)) return fail(reply, 400, 'widget onbekend op dit bord');
}
return { boardId, widgetId: widgetId ?? null, mode: m };
};
const PUPIL_ASSIGNMENT_WIDGETS = new Set([
'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes',
'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money',
]);
const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library';
const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']);
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']);
const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => {
if (rawTheme == null) return { theme: null };
if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType))
return fail(reply, 400, `thema is niet beschikbaar voor stap ${index + 1}`);
if (!rawTheme || typeof rawTheme !== 'object' || Array.isArray(rawTheme))
return fail(reply, 400, `ongeldig thema in stap ${index + 1}`);
const id = typeof rawTheme.id === 'string' ? rawTheme.id.trim() : '';
const name = typeof rawTheme.name === 'string' ? rawTheme.name.trim() : '';
if (!id || id.length > 80 || !name || name.length > 80 || !Array.isArray(rawTheme.words)
|| !rawTheme.words.length || rawTheme.words.length > 120)
return fail(reply, 400, `ongeldig thema in stap ${index + 1}`);
const words = [];
for (const rawWord of rawTheme.words) {
if (!Array.isArray(rawWord) || typeof rawWord[0] !== 'string') {
return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`);
}
const word = rawWord[0].trim();
const picture = rawWord[1] == null ? '' : String(rawWord[1]);
const yearGroup = rawWord[2] == null ? null : Number(rawWord[2]);
const anchor = rawWord[3] == null ? null : String(rawWord[3]).trim();
if (!word || word.length > 80 || picture.length > 20_000
|| (yearGroup != null && (!Number.isInteger(yearGroup) || yearGroup < 1 || yearGroup > 8))
|| (anchor != null && anchor.length > 80)) {
return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`);
}
words.push([word, picture, yearGroup, anchor]);
}
const theme = { id, name, words };
if (JSON.stringify(theme).length > 160_000)
return fail(reply, 400, `thema in stap ${index + 1} is te groot`);
return { theme };
};
const parseAssignmentBody = (req, reply) => {
const raw = req.body ?? {};
if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) {
const share = parseShareBody(req, reply);
if (share.error) return share;
return { ...share, title: '', sequence: [] };
}
if (raw.sequence.length > 12) return fail(reply, 400, 'een opdrachtenreeks heeft maximaal 12 stappen');
const title = typeof raw.title === 'string' ? raw.title.trim() : '';
if (!title || title.length > 80) return fail(reply, 400, 'geef de opdrachtenreeks een titel van maximaal 80 tekens');
const sequence = [];
for (let index = 0; index < raw.sequence.length; index += 1) {
const item = raw.sequence[index] ?? {};
const mode = item.mode ?? 'werken';
if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`);
if (item.widgetType != null) {
if (typeof item.widgetType !== 'string' || !PUPIL_ASSIGNMENT_WIDGETS.has(item.widgetType))
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`);
const parsedTheme = parseAssignmentTheme(item.theme, item.widgetType, index, reply);
if (parsedTheme.error) return parsedTheme;
sequence.push({
id: `step-${index + 1}`,
source: 'library',
boardId: ASSIGNMENT_LIBRARY_BOARD,
widgetId: `assignment-step-${index + 1}`,
widgetType: item.widgetType,
mode,
requiresProgress: PROGRESS_ASSIGNMENT_WIDGETS.has(item.widgetType),
theme: parsedTheme.theme,
});
continue;
}
const boardId = item.boardId || raw.boardId;
const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null;
if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`);
if (!item.widgetId || typeof item.widgetId !== 'string' || item.widgetId.length > 100)
return fail(reply, 400, `ongeldige widget in stap ${index + 1}`);
const widget = (board.data?.widgets || []).find((candidate) => candidate.wid === item.widgetId);
if (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`);
if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id))
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`);
sequence.push({ id: `step-${index + 1}`, boardId, widgetId: item.widgetId, mode });
}
if (JSON.stringify(sequence).length > 512_000)
return fail(reply, 400, 'opdrachtenreeks is te groot');
const first = sequence[0];
return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence };
};
const saveAssignment = async (targetColumn, targetId, teacherId, share) => {
const row = (await pool.query(
`INSERT INTO assignments (${targetColumn}, teacher_id, board_id, widget_id, mode, title, sequence)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (${targetColumn}) WHERE ${targetColumn} IS NOT NULL
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5,
title = $6, sequence = $7, updated_at = now()
RETURNING id`,
[targetId, teacherId, share.boardId, share.widgetId, share.mode, share.title, JSON.stringify(share.sequence)])).rows[0];
if (row?.id) await pool.query('DELETE FROM assignment_step_progress WHERE assignment_id = $1', [row.id]);
};
app.get('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
const a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [c.id])).rows[0];
return { assignment: a ? await describeAssignment(a) : null };
});
app.put('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseAssignmentBody(req, reply);
if (share.error) return share;
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await saveAssignment('class_id', c.id, req.user.id, share);
return { ok: true };
});
app.delete('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM assignments WHERE class_id = $1', [c.id]);
return { ok: true };
});
const pupilAccessible = async (req, u) => {
if (!u || u.role !== 'pupil' || !sameSchool(req, u)) return false;
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) return false;
return true;
};
app.get('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
const a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [u.id])).rows[0];
return { assignment: a ? await describeAssignment(a) : null };
});
app.put('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseAssignmentBody(req, reply);
if (share.error) return share;
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await saveAssignment('pupil_id', u.id, req.user.id, share);
return { ok: true };
});
app.delete('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
return { ok: true };
});
// Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
// Bij een reeks wordt uitsluitend de eerste nog niet afgeronde stap live uit
// het bord van de leerkracht geladen; losse toewijzingen blijven ongewijzigd.
const effectiveAssignment = async (user) => {
let assignment = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [user.id])).rows[0];
if (!assignment && user.class_id) {
assignment = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [user.class_id])).rows[0];
}
return assignment || null;
};
const assignmentWidgets = async (assignment, boardId, widgetId, step = null) => {
if (step?.source === 'library' && step.widgetType) {
const state = step.theme ? {
assignmentThemeId: step.theme.id,
assignmentThemeName: step.theme.name,
assignmentWords: step.theme.words,
} : {};
return [{ id: step.widgetType, wid: step.widgetId, state }];
}
const teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0];
const board = teacher ? findBoardById(teacher.data, boardId) : null;
let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state }));
if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId);
return widgets;
};
const liveSessions = registerLiveSessions(app, {
pool, need, fail, progressRoles: PERMISSIONS['progress.view'],
sameSchool, teacherOnly, classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
});
app.get('/my/assignment', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const assignment = await effectiveAssignment(req.user);
if (!assignment) return { widgets: [], mode: 'werken', updatedAt: null, sequence: null };
const steps = Array.isArray(assignment.sequence) ? assignment.sequence : [];
if (!steps.length) {
return {
widgets: await assignmentWidgets(assignment, assignment.board_id, assignment.widget_id),
mode: assignment.mode || 'werken', updatedAt: assignment.updated_at, sequence: null,
};
}
const completedRows = (await pool.query(
'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
[assignment.id, req.user.id])).rows;
const completedIds = new Set(completedRows.map((row) => row.step_id));
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
const current = currentIndex >= 0 ? steps[currentIndex] : null;
const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)
: assignment.updated_at;
const ready = !current || current.mode === 'kijken' || current.requiresProgress === false || !!(await pool.query(
`SELECT 1 FROM progress_events
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
[req.user.id, current.widgetId, readySince])).rows[0];
return {
widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId, current) : [],
mode: current?.mode || 'werken',
updatedAt: assignment.updated_at,
sequence: {
title: assignment.title || '',
steps: steps.map((step) => {
const described = { id: step.id, mode: step.mode, completed: completedIds.has(step.id) };
if (step.source === 'library' && step.widgetType) {
described.widgetType = step.widgetType;
described.themeName = step.theme?.name || null;
}
return described;
}),
currentIndex: currentIndex < 0 ? steps.length : currentIndex,
completed: completedIds.size,
total: steps.length,
done: currentIndex < 0,
ready,
},
};
});
app.post('/my/assignment/steps/:stepId/complete', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const assignment = await effectiveAssignment(req.user);
const steps = Array.isArray(assignment?.sequence) ? assignment.sequence : [];
if (!assignment || !steps.length) return fail(reply, 400, 'geen actieve opdrachtenreeks');
const completedRows = (await pool.query(
'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
[assignment.id, req.user.id])).rows;
const completedIds = new Set(completedRows.map((row) => row.step_id));
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
const current = currentIndex >= 0 ? steps[currentIndex] : null;
if (!current || current.id !== req.params.stepId) return fail(reply, 409, 'deze stap is niet de huidige opdracht');
if (current.mode !== 'kijken' && current.requiresProgress !== false) {
const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)
: assignment.updated_at;
const evidence = (await pool.query(
`SELECT 1 FROM progress_events
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
[req.user.id, current.widgetId, readySince])).rows[0];
if (!evidence) return fail(reply, 409, 'rond eerst een opdracht in de widget af');
}
const inserted = (await pool.query(
`INSERT INTO assignment_step_progress (assignment_id, pupil_id, step_id)
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING step_id`,
[assignment.id, req.user.id, current.id])).rows[0];
let points = Number(req.user.reward_points || 0);
let unlocked = [];
if (inserted) {
points = Number((await pool.query(
'UPDATE users SET reward_points = reward_points + 1 WHERE id = $1 RETURNING reward_points',
[req.user.id])).rows[0]?.reward_points || points + 1);
unlocked = (await pool.query(
`INSERT INTO user_avatar_rewards (user_id, reward_key)
SELECT $1, reward_key FROM (VALUES
('head:star', 1), ('body:medal', 3), ('dance:robot', 6)
) AS rewards(reward_key, required_points)
WHERE required_points <= $2
ON CONFLICT DO NOTHING RETURNING reward_key`,
[req.user.id, points])).rows.map((row) => row.reward_key);
}
return { ok: true, points, unlocked, done: completedIds.size + (inserted ? 1 : 0) >= steps.length };
});
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
// Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven,
// nu met vakgebied als extra dimensie (subject: taal/rekenen/world - dezelfde
// waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de
// niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de
// leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is
// de fijnmazige verfijning daarbinnen die widgets nu al kennen.
const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world'];
const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) });
const parseLevelBody = (req, reply) => {
const { subject, yearGroup, level } = req.body ?? {};
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const yg = Number(yearGroup);
if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep');
const lvl = Number(level);
if (!Number.isInteger(lvl) || lvl < 1 || lvl > 3) return fail(reply, 400, 'ongeldig niveau');
return { subject, yearGroup: yg, level: lvl };
};
app.get('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
const cls = (await pool.query(
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject])).rows[0];
const overrides = (await pool.query(
`SELECT pl.* FROM pupil_levels pl JOIN users u ON u.id = pl.pupil_id
WHERE u.class_id = $1 AND pl.subject = $2`, [c.id, subject])).rows;
return {
classDefault: cls ? describePupilLevel(cls) : null,
pupilOverrides: overrides.map((r) => ({ pupilId: Number(r.pupil_id), ...describePupilLevel(r) })),
};
});
app.put('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const body = parseLevelBody(req, reply);
if (body.error) return body;
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query(
`INSERT INTO pupil_levels (class_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (class_id, subject) WHERE class_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[c.id, body.subject, body.yearGroup, body.level]);
return { ok: true };
});
app.delete('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject]);
return { ok: true };
});
app.get('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
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 row = (await pool.query(
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject])).rows[0];
return { level: row ? describePupilLevel(row) : null };
});
app.put('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const body = parseLevelBody(req, reply);
if (body.error) return body;
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[u.id, body.subject, body.yearGroup, body.level]);
return { ok: true };
});
app.delete('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject]);
return { ok: true };
});
// Leerling-kant: effectieve groep/niveau (eigen override wint van de klas),
// zelfde resolutievorm als /my/assignment hierboven. Geen instelling ->
// {yearGroup:null, level:1}, gelijk aan de bestaande widget-standaardwaarde.
app.get('/my/level', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
let row = (await pool.query(
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [req.user.id, subject])).rows[0];
if (!row && req.user.class_id) {
row = (await pool.query(
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [req.user.class_id, subject])).rows[0];
}
return row ? describePupilLevel(row) : { yearGroup: null, level: 1 };
});
// Alleen een leerling zonder school beheert het eigen niveau. Zodra een
// school het account adopteert, neemt de klas/leerlinginstelling van de
// leerkracht het automatisch over en wordt zelf wijzigen geweigerd.
app.put('/my/level', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply);
if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[req.user.id, body.subject, body.yearGroup, body.level]);
return { ok: true, level: { yearGroup: body.yearGroup, level: body.level } };
});
// ---- voortgang (leerling-omgeving) ---------------------------------------------
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve
// toewijzing (net als /my/assignment), nooit van de client, zodat een leerling geen
// voortgang onder een andere leerkracht kan wegschrijven.
const PROGRESS_LIMIT = 200;
const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0));
// Optioneel detail (welke opdracht - som/woord - en het gegeven/juiste
// antwoord): decoratief, nooit een reden om de hele log-poging te laten
// falen. Alleen bewaard als het een plain object is en niet absurd groot;
// anders stilzwijgend genegeerd (detail = null), de rest van de melding
// (attempts/correct/stars) wordt gewoon gelogd.
const sanitizeProgressDetail = (detail) => {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null;
try {
if (JSON.stringify(detail).length > 1000) return null;
} catch { return null; }
return detail;
};
app.post('/my/progress', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const { widgetId, widgetType, attempts, correct, stars, detail, liveSessionId } = req.body ?? {};
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
let a = await effectiveAssignment(req.user);
if (a && Array.isArray(a.sequence) && a.sequence.length) {
const completed = new Set((await pool.query(
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
[a.id, req.user.id])).rows.map((row) => row.step_id));
const current = a.sequence.find((step) => !completed.has(step.id));
if (!current || current.widgetId !== widgetId) return fail(reply, 409, 'deze widget is niet de huidige stap');
}
// 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');
const cleanAttempts = clampCount(attempts);
const cleanCorrect = clampCount(correct);
if (await liveSessions.isPaused(req.user.id, liveSessionId))
return fail(reply, 409, 'de leerkracht heeft deze opdracht tijdelijk gepauzeerd');
await pool.query(
`INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, cleanAttempts, cleanCorrect, clampCount(stars), sanitizeProgressDetail(detail)]);
await liveSessions.recordProgress(req.user.id, liveSessionId, cleanAttempts, cleanCorrect);
return { ok: true };
});
const describeProgressEvent = (e) => ({
widgetId: e.widget_id,
widgetType: e.widget_type,
attempts: e.attempts,
correct: e.correct,
stars: e.stars,
detail: e.detail || null,
createdAt: e.created_at,
});
// Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder
// gelden de normale staf-regels (progress.view + pupilAccessible).
const parentLinked = async (parentId, pupilId) => !!(await pool.query(
'SELECT 1 FROM parent_children WHERE parent_id = $1 AND pupil_id = $2',
[parentId, pupilId])).rows[0];
app.get('/progress/pupil/:id', async (req, reply) => {
need(req, reply);
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (req.user.role === 'parent') {
if (!(await parentLinked(req.user.id, u.id))) return fail(reply, 403, 'geen rechten');
} else {
if (!can(req.user.allRoles, 'progress.view')) return fail(reply, 403, 'geen rechten');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
}
const rows = (await pool.query(
'SELECT * FROM progress_events WHERE pupil_id = $1 ORDER BY created_at DESC LIMIT $2',
[u.id, PROGRESS_LIMIT])).rows;
return { events: rows.map(describeProgressEvent) };
});
app.get('/progress/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['progress.view']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
/* standaard blijft de korte activiteitenweergave (200) - de resultaten-
export vraagt expliciet een hogere limiet op om niet stilzwijgend af
te kappen bij een drukke klas; begrensd op 5000 tegen misbruik. */
const limit = Math.max(1, Math.min(5000, Number(req.query.limit) || PROGRESS_LIMIT));
const rows = (await pool.query(
`SELECT pe.*, u.display_name, u.username FROM progress_events pe
JOIN users u ON u.id = pe.pupil_id
WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`,
[c.id, limit])).rows;
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
});
app.get('/progress/dashboard/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['progress.view']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
const pupils = (await pool.query(
`SELECT u.id, u.display_name, u.username,
COALESCE(SUM(pe.attempts), 0) AS attempts,
COALESCE(SUM(pe.correct), 0) AS correct,
MAX(pe.created_at) AS last_activity
FROM users u LEFT JOIN progress_events pe ON pe.pupil_id = u.id
WHERE u.class_id = $1 AND u.role = 'pupil'
GROUP BY u.id, u.display_name, u.username
ORDER BY COALESCE(u.display_name, u.username)`, [c.id])).rows;
const assignments = (await pool.query(
`SELECT * FROM assignments
WHERE class_id = $1 OR pupil_id IN (SELECT id FROM users WHERE class_id = $1 AND role = 'pupil')`, [c.id])).rows;
const completed = (await pool.query(
`SELECT asp.assignment_id, asp.pupil_id, COUNT(*) AS completed
FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id
WHERE u.class_id = $1 GROUP BY asp.assignment_id, asp.pupil_id`, [c.id])).rows;
const classAssignment = assignments.find((assignment) => Number(assignment.class_id) === Number(c.id));
const completedMap = new Map(completed.map((row) => [`${row.assignment_id}:${row.pupil_id}`, Number(row.completed)]));
const overview = pupils.map((pupil) => {
const assignment = assignments.find((item) => Number(item.pupil_id) === Number(pupil.id)) || classAssignment;
const total = Array.isArray(assignment?.sequence) ? assignment.sequence.length : 0;
const done = assignment ? (completedMap.get(`${assignment.id}:${pupil.id}`) || 0) : 0;
const attempts = Number(pupil.attempts || 0);
const correct = Number(pupil.correct || 0);
const accuracy = attempts ? Math.round((correct / attempts) * 100) : 0;
const started = !!assignment && (!!done || (pupil.last_activity
&& new Date(pupil.last_activity) >= new Date(assignment.updated_at)));
const assignmentComplete = !!assignment && (total ? done >= total : started);
let status = 'notStarted';
if (total && done >= total) status = 'complete';
else if (attempts >= 5 && accuracy < 60) status = 'support';
else if (attempts || done) status = 'active';
return {
pupilId: Number(pupil.id), pupilName: pupil.display_name || pupil.username,
attempts, correct, accuracy, lastActivity: pupil.last_activity,
sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null,
assigned: !!assignment, started, assignmentComplete, status,
};
});
const summary = overview.reduce((counts, pupil) => {
counts[pupil.status] += 1;
return counts;
}, { complete: 0, active: 0, support: 0, notStarted: 0 });
const funnel = {
total: overview.length,
assigned: overview.filter((pupil) => pupil.assigned).length,
started: overview.filter((pupil) => pupil.started).length,
completed: overview.filter((pupil) => pupil.assignmentComplete).length,
};
return { pupils: overview, summary, funnel };
});
// ---- 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,
})) };
});
app.get('/parent/children/:id/levels', async (req, reply) => {
needParent(req, reply);
if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
if (child.school_id != null) return { schoolManaged: true, levels: {} };
const rows = (await pool.query(
"SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen')",
[child.id])).rows;
return {
schoolManaged: false,
levels: Object.fromEntries(rows.map((row) => [row.subject, describePupilLevel(row)])),
};
});
app.put('/parent/children/:id/level', async (req, reply) => {
needParent(req, reply);
if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply);
if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[child.id, body.subject, body.yearGroup, body.level]);
return { ok: true };
});
// 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;
}
// De thuiskeuze mag na adoptie niet als leerling-override boven de
// klasinstelling blijven hangen; vanaf nu bepaalt de leerkracht dit.
await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1', [r.pupil_id]);
// het kind hoort nu bij deze school: ook aanmeldingen bij andere
// scholen zijn daarmee vervallen
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
return { ok: true };
}
if (b.action === 'match') {
if (!b.pupilId) return fail(reply, 400, 'leerling verplicht');
const target = (await pool.query('SELECT * FROM users WHERE id = $1', [b.pupilId])).rows[0];
if (!target || target.role !== 'pupil' || Number(target.school_id) !== Number(r.school_id))
return fail(reply, 400, 'leerling hoort niet bij deze school');
await pool.query(
`INSERT INTO parent_children (parent_id, pupil_id)
SELECT parent_id, $1 FROM parent_children WHERE pupil_id = $2
ON CONFLICT DO NOTHING`, [target.id, r.pupil_id]);
const home = (await pool.query(
`SELECT (data IS NULL OR data = '{}'::jsonb) AS empty FROM users WHERE id = $1`,
[r.pupil_id])).rows[0];
if (home?.empty) {
// leeg thuisaccount opruimen (cascade ruimt koppelingen en verzoeken op)
await pool.query('DELETE FROM users WHERE id = $1', [r.pupil_id]);
} else {
// thuisaccount met werk erin blijft bestaan; alleen de verzoeken weg
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
}
return { ok: true };
}
return fail(reply, 400, 'onbekende actie');
});
// ---- gedeelde bibliotheek --------------------------------------------------
// Systeembreed (school_id NULL, gepubliceerd door de systeemmanager, voor
// iedereen zichtbaar) of per school (alleen zichtbaar binnen die school).
// Andere gebruikers KOPIËREN een item naar hun eigen collectie; er wordt
// niet live in gedeelde items gewerkt. Beheren (hernoemen/verplaatsen/
// verwijderen) mag de systeemmanager altijd, de eigenaar altijd, en de
// schoolbeheerder voor items van de eigen school.
const SHARED_KINDS = ['board', 'anchor'];
const sharedStaff = (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); }
};
// Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij,
// alleen de schoolbeheerder en de systeemmanager nog.
const canManageShared = (req, item) =>
req.user.allRoles.includes('super')
|| (item.school_id != null && req.user.allRoles.includes('admin')
&& Number(item.school_id) === Number(req.user.school_id))
|| (Number(item.owner_id) === Number(req.user.id) && !item.readonly);
// Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd,
// de schoolbeheerder voor items van de eigen school (niet de leerkracht).
const canLockShared = (req, item) =>
req.user.allRoles.includes('super')
|| (item.school_id != null && req.user.allRoles.includes('admin')
&& Number(item.school_id) === Number(req.user.school_id));
const sharedItemVisible = (req, item) =>
req.user.allRoles.includes('super')
|| item.school_id == null
|| Number(item.school_id) === Number(req.user.school_id);
const validSharedName = (n) => typeof n === 'string' && n.trim() && n.trim().length <= 40;
const validSharedFolder = (f) => typeof f === 'string' && f.length <= 200 && !f.includes('//') && !/[\x00-\x1f]/.test(f);
const describeShared = (req, it) => ({
id: Number(it.id),
name: it.name,
folder: it.folder || '',
scope: it.school_id == null ? 'global' : 'school',
ownerName: it.owner_name || null,
readonly: !!it.readonly,
canManage: canManageShared(req, it),
canLock: canLockShared(req, it),
});
app.get('/shared/:kind', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const rows = req.user.allRoles.includes('super')
? (await pool.query(
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows
: (await pool.query(
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2)
ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows;
return { items: rows.map((it) => describeShared(req, it)) };
});
app.get('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
return { item: { ...describeShared(req, it), data: it.data } };
});
app.post('/shared/:kind', { bodyLimit: 6 * 1024 * 1024 }, async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const { name, folder, data, scope } = req.body ?? {};
if (!validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige data');
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 5 * 1024 * 1024) return fail(reply, 413, 'data te groot');
let schoolId = null;
if (scope === 'global') {
need(req, reply, PERMISSIONS['shared.global.manage']);
} else if (scope === 'school') {
need(req, reply, PERMISSIONS['shared.school.manage']);
if (req.user.school_id == null) return fail(reply, 400, 'geen school gekoppeld aan dit account');
schoolId = req.user.school_id;
} else return fail(reply, 400, 'ongeldige scope');
const r = await pool.query(
`INSERT INTO shared_library (school_id, owner_id, kind, folder, name, data)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[schoolId, req.user.id, kind, folder || '', name.trim(), data]);
return { ok: true, id: Number(r.rows[0].id) };
});
// map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map
// op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de
// gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door
// wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager)
app.patch('/shared/:kind/folder', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const { scope, from, to, readonly } = req.body ?? {};
if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope');
if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map');
if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen');
if (to !== undefined && (!validSharedFolder(to) || !to)) return fail(reply, 400, 'ongeldige map');
const rows = scope === 'global'
? (await pool.query(
`SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`,
[kind, from, from + '/%'])).rows
: (await pool.query(
`SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`,
[kind, req.user.school_id, from, from + '/%'])).rows;
if (to !== undefined){
if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map');
await pool.query(
`UPDATE shared_library SET folder = $1 || substr(folder, $2)
WHERE id = ANY($3)`,
[to, from.length + 1, rows.map((it) => it.id)]);
}
if (typeof readonly === 'boolean'){
if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]);
}
return { ok: true };
});
app.patch('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
const { name, folder, readonly } = req.body ?? {};
if (name !== undefined || folder !== undefined){
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]);
if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]);
}
if (readonly !== undefined){
if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde');
if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]);
}
return { ok: true };
});
app.delete('/shared/:kind/:id', async (req, reply) => {
sharedStaff(req, reply);
const { kind } = req.params;
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM shared_library WHERE id = $1', [it.id]);
return { ok: true };
});
}
// Eerste super-beheerder aanmaken als die nog niet bestaat.
export async function bootstrapSuper(pool, log) {
const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'");
if (r.rows[0].n > 0) return;
const username = process.env.SUPER_USER || 'beheerder';
const password = process.env.SUPER_PASS;
if (!password) throw new Error('SUPER_PASS is verplicht bij het aanmaken van de eerste super-beheerder');
const passwordError = checkPassword(password, 'super');
if (passwordError) throw new Error(`ongeldige SUPER_PASS: ${passwordError}`);
await pool.query(
`INSERT INTO users (username, role, display_name, password_hash)
VALUES ($1, 'super', $1, $2)`,
[username, await hashPassword(password)]);
log.warn(`Eerste super-beheerder aangemaakt: ${username}. Het wachtwoord wordt niet gelogd.`);
}