All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- Nieuwe rol parent (school_id NULL): registreert zichzelf via POST /auth/register-parent (rate-limited, stafwachtwoordregels, directe sessie) - Migratie 013: parent_children-koppeltabel, eenmalige users.parent_code, school_link_requests (aanmeldverzoeken, uniek per kind+school) - Ouderroutes: kinderen bekijken (met school/klas/aanmeldstatus), thuiskind aanmaken (pupil zonder school, wachtwoord eenmalig terug), koppelen via ouder-code, aanmelden bij school, ontkoppelen - Staf: ouder-code genereren per leerling, aanmeldingen bekijken en afhandelen (adopteren in klas met optionele nieuwe gebruikersnaam, koppelen aan bestaande leerling met opruiming van lege thuisaccounts, afwijzen) - Voortgang: thuiskind zonder toewijzing logt met de gekoppelde ouder als leerkracht (bord "thuis"); gekoppelde ouders mogen de voortgang van hun kind opvragen - 18 nieuwe servertests (registratie, koppelmodel, aanmeldflow, resolve-acties, voortgang) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
1181 lines
64 KiB
JavaScript
1181 lines
64 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';
|
|
|
|
export default async function api(app) {
|
|
const pool = app.pg;
|
|
// Voorkomt meetbaar snellere antwoorden voor niet-bestaande gebruikers.
|
|
const dummyPasswordHash = await hashPassword(newLinkCode());
|
|
const cookieSecure = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production';
|
|
const cookieOptions = { path: '/', httpOnly: true, secure: cookieSecure, sameSite: 'strict' };
|
|
const setSessionCookie = (reply, token, role) => reply.setCookie(SESSION_COOKIE, token, {
|
|
...cookieOptions, maxAge: sessionDaysForRole(role) * 24 * 60 * 60,
|
|
});
|
|
const clearSessionCookie = (reply) => reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
|
|
|
// ---- helpers --------------------------------------------------------------
|
|
const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; };
|
|
|
|
app.decorateRequest('user', null);
|
|
app.addHook('preHandler', async (req, reply) => {
|
|
if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) {
|
|
const expectedOrigin = `${req.protocol}://${req.headers.host}`;
|
|
if (req.headers.origin !== expectedOrigin) return fail(reply, 403, 'ongeldige origin');
|
|
}
|
|
req.user = await userFromRequest(pool, req);
|
|
});
|
|
app.addHook('onResponse', async (req, reply) => {
|
|
if ([401, 403, 429].includes(reply.statusCode)) {
|
|
req.log.warn({ securityEvent: true, status: reply.statusCode, method: req.method,
|
|
url: req.url, ip: req.ip, userId: req.user?.id ?? null }, 'security-relevant response');
|
|
}
|
|
});
|
|
const need = (req, reply, roles) => {
|
|
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); }
|
|
if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); }
|
|
};
|
|
// groepsleiding-only regels gelden alleen als iemand *uitsluitend* teacher is;
|
|
// wie er (via een extra rol) ook admin of super bij heeft, valt onder de
|
|
// ruimere regels van die rol i.p.v. de leerling-only-beperking van teacher.
|
|
const teacherOnly = (req) =>
|
|
req.user.allRoles.includes('teacher')
|
|
&& !req.user.allRoles.includes('admin')
|
|
&& !req.user.allRoles.includes('super');
|
|
// welke rollen iemand mag aanmaken: unie over al hun rollen (hoofd + extra)
|
|
const creatableRoles = (req) =>
|
|
[...new Set(req.user.allRoles.flatMap((r) => CREATABLE_ROLES[r] || []))];
|
|
const sameSchool = (req, u) =>
|
|
req.user.allRoles.includes('super') || Number(u.school_id) === Number(req.user.school_id);
|
|
|
|
app.setErrorHandler((err, req, reply) => {
|
|
if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message });
|
|
req.log.error(err);
|
|
reply.code(500).send({ error: 'serverfout' });
|
|
});
|
|
|
|
// ---- version --------------------------------------------------------------
|
|
app.get('/version', async () => ({
|
|
name: 'teach',
|
|
version: process.env.APP_VERSION ?? 'dev',
|
|
sha: process.env.GIT_SHA || undefined,
|
|
}));
|
|
|
|
// ---- publiek: scholenlijst voor het inlogscherm ----------------------------
|
|
app.get('/schools', async () => {
|
|
const r = await pool.query('SELECT id, name FROM schools ORDER BY name');
|
|
return r.rows.map((s) => ({ id: Number(s.id), name: s.name }));
|
|
});
|
|
|
|
// ---- auth -------------------------------------------------------------------
|
|
app.post('/auth/login', {
|
|
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
|
|
}, async (req, reply) => {
|
|
const { schoolId, username, password } = req.body ?? {};
|
|
if (typeof username !== 'string' || typeof password !== 'string'
|
|
|| !username.trim() || username.length > 30 || password.length > 128)
|
|
return fail(reply, 400, 'ongeldige naam of wachtwoord');
|
|
const r = schoolId
|
|
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username.trim()])
|
|
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username.trim()]);
|
|
const u = r.rows[0];
|
|
const passwordOk = await verifyPassword(password, u?.password_hash || dummyPasswordHash);
|
|
if (!u || !passwordOk) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
|
|
// account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk
|
|
await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [u.id]);
|
|
const token = await createSession(pool, u.id, u.role);
|
|
setSessionCookie(reply, token, u.role);
|
|
return { user: publicUser(u) };
|
|
});
|
|
|
|
// Account activeren met een koppelcode (groepsleiding/beheerders)
|
|
app.post('/auth/link', {
|
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
|
|
}, async (req, reply) => {
|
|
const { code, password } = req.body ?? {};
|
|
if (typeof code !== 'string' || code.length > 20 || typeof password !== 'string' || password.length > 128)
|
|
return fail(reply, 400, 'ongeldige koppelcode of wachtwoord');
|
|
const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
|
|
const u = r.rows[0];
|
|
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
|
|
const err = checkPassword(password, u.role);
|
|
if (err) return fail(reply, 400, err);
|
|
const hash = await hashPassword(password);
|
|
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]);
|
|
const token = await createSession(pool, u.id, u.role);
|
|
setSessionCookie(reply, token, u.role);
|
|
return { user: publicUser({ ...u, password_hash: hash, link_code: null }) };
|
|
});
|
|
|
|
app.post('/auth/logout', async (req, reply) => {
|
|
const token = sessionTokenFromRequest(req);
|
|
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [hashSessionToken(token)]);
|
|
clearSessionCookie(reply);
|
|
return { ok: true };
|
|
});
|
|
|
|
app.get('/auth/me', async (req, reply) => {
|
|
if (!req.user) return fail(reply, 401, 'niet ingelogd');
|
|
return { user: publicUser(req.user) };
|
|
});
|
|
|
|
// Eigen wachtwoord wijzigen - niet voor leerlingen
|
|
app.patch('/auth/password', {
|
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
|
}, async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['password.own.change']);
|
|
const { currentPassword, password } = req.body ?? {};
|
|
if (typeof currentPassword !== 'string' || typeof password !== 'string') return fail(reply, 400, 'ongeldig wachtwoord');
|
|
if (!(await verifyPassword(currentPassword, req.user.password_hash))) return fail(reply, 401, 'huidig wachtwoord onjuist');
|
|
const err = checkPassword(password, req.user.role);
|
|
if (err) return fail(reply, 400, err);
|
|
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
|
|
[await hashPassword(password), req.user.id]);
|
|
const token = sessionTokenFromRequest(req);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND token <> $2',
|
|
[req.user.id, token ? hashSessionToken(token) : '']);
|
|
return { ok: true };
|
|
});
|
|
|
|
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
|
|
app.get('/me/data', async (req, reply) => {
|
|
need(req, reply);
|
|
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
|
|
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
|
|
});
|
|
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
|
|
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
|
|
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
|
|
// (oude clients blijven werken, laatste schrijver wint dan zoals voorheen).
|
|
const revFromRequest = (req) => {
|
|
const h = req.headers['x-data-rev'];
|
|
if (h === undefined) return null;
|
|
const n = Number(h);
|
|
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
};
|
|
// Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar
|
|
// hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en
|
|
// maximaal 10 versies per gebruiker.
|
|
const SNAPSHOT_MIN_AGE = "10 minutes";
|
|
const SNAPSHOT_KEEP = 10;
|
|
const snapshotUserData = async (userId) => {
|
|
const fresh = (await pool.query(
|
|
`SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`,
|
|
[userId, SNAPSHOT_MIN_AGE])).rows[0];
|
|
if (fresh) return;
|
|
await pool.query(
|
|
`INSERT INTO user_data_versions (user_id, data)
|
|
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
|
|
[userId]);
|
|
await pool.query(
|
|
`DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN (
|
|
SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
|
|
)`, [userId, SNAPSHOT_KEEP]);
|
|
};
|
|
const writeUserData = async (req, reply, data) => {
|
|
await snapshotUserData(req.user.id);
|
|
const rev = revFromRequest(req);
|
|
const r = rev === null
|
|
? await pool.query(
|
|
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
|
|
[data, req.user.id])
|
|
: await pool.query(
|
|
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev',
|
|
[data, req.user.id, rev]);
|
|
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
|
|
return { ok: true, rev: Number(r.rows[0].data_rev) };
|
|
};
|
|
app.get('/me/data/versions', async (req, reply) => {
|
|
need(req, reply);
|
|
if (req.user.role === 'pupil') 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 (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten');
|
|
const { versionId } = req.body ?? {};
|
|
if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie');
|
|
const v = (await pool.query(
|
|
'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2',
|
|
[versionId, req.user.id])).rows[0];
|
|
if (!v) return fail(reply, 404, 'versie onbekend');
|
|
// de huidige data eerst zelf als versie bewaren, zodat een herstel ook
|
|
// weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel)
|
|
await pool.query(
|
|
`INSERT INTO user_data_versions (user_id, data)
|
|
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
|
|
[req.user.id]);
|
|
const r = await pool.query(
|
|
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
|
|
[v.data, req.user.id]);
|
|
return { data: v.data, rev: Number(r.rows[0].data_rev) };
|
|
});
|
|
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
|
need(req, reply);
|
|
const data = req.body ?? {};
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
|
|
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
|
|
return writeUserData(req, reply, data);
|
|
});
|
|
// Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set)
|
|
// i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de
|
|
// volledige PUT hierboven, die blijft bestaan voor restore/eerste save.
|
|
const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2, anchors: 5 }; // cap in MB
|
|
app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
|
need(req, reply);
|
|
const section = req.params.section;
|
|
if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie');
|
|
const part = req.body ?? {};
|
|
// anchors is een lijst, de overige secties zijn objecten
|
|
if (!part || typeof part !== 'object') return fail(reply, 400, 'ongeldige data');
|
|
if (Array.isArray(part) !== (section === 'anchors')) return fail(reply, 400, 'ongeldige data');
|
|
if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024)
|
|
return fail(reply, 413, 'data te groot');
|
|
await snapshotUserData(req.user.id);
|
|
const rev = revFromRequest(req);
|
|
const set = `data = jsonb_set(coalesce(data, '{}'::jsonb), $1, $2::jsonb), data_rev = data_rev + 1, data_updated_at = now()`;
|
|
const r = rev === null
|
|
? await pool.query(`UPDATE users SET ${set} WHERE id = $3 RETURNING data_rev`,
|
|
[`{${section}}`, JSON.stringify(part), req.user.id])
|
|
: await pool.query(`UPDATE users SET ${set} WHERE id = $3 AND data_rev = $4 RETURNING data_rev`,
|
|
[`{${section}}`, JSON.stringify(part), req.user.id, rev]);
|
|
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
|
|
return { ok: true, rev: Number(r.rows[0].data_rev) };
|
|
});
|
|
|
|
// ---- scholen (super) ---------------------------------------------------------
|
|
app.post('/admin/schools', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['schools.manage']);
|
|
const { name } = req.body ?? {};
|
|
if (!name || typeof name !== 'string' || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
|
|
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
|
|
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
|
});
|
|
app.patch('/admin/schools/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['schools.manage']);
|
|
const { name } = req.body ?? {};
|
|
if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
|
|
const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]);
|
|
if (!r.rows[0]) return fail(reply, 404, 'school onbekend');
|
|
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
|
});
|
|
app.delete('/admin/schools/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['schools.manage']);
|
|
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Multireset: alle leerlingwachtwoorden van een hele school in één keer
|
|
// vernieuwen. Alleen schoolbeheerder (eigen school) en systeemmanager.
|
|
app.post('/admin/schools/:id/reset-passwords', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['schools.resetPasswords']);
|
|
const schoolId = req.params.id;
|
|
if (!req.user.allRoles.includes('super') && Number(schoolId) !== Number(req.user.school_id))
|
|
return fail(reply, 403, 'geen rechten');
|
|
const pupils = (await pool.query(
|
|
"SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows;
|
|
const users = [];
|
|
for (const p of pupils) {
|
|
const pw = pupilPassword();
|
|
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
|
[await hashPassword(pw), p.id]);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
|
|
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
|
}
|
|
return { users };
|
|
});
|
|
|
|
// ---- klassen -----------------------------------------------------------------
|
|
app.get('/admin/classes', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['classes.view']);
|
|
const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id;
|
|
if (!schoolId) return { classes: [] };
|
|
const r = await pool.query(
|
|
`SELECT c.id, c.name,
|
|
COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids
|
|
FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id
|
|
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]);
|
|
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
|
|
});
|
|
app.post('/admin/classes', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['classes.manage']);
|
|
const { name, school } = req.body ?? {};
|
|
const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id;
|
|
if (!name || typeof name !== 'string' || name.trim().length > 80 || !schoolId) return fail(reply, 400, 'ongeldige naam of school');
|
|
const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]);
|
|
return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
|
});
|
|
app.delete('/admin/classes/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['classes.manage']);
|
|
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
|
|
if (!r.rows[0]) return fail(reply, 404, 'klas onbekend');
|
|
if (!req.user.allRoles.includes('super') && Number(r.rows[0].school_id) !== Number(req.user.school_id))
|
|
return fail(reply, 403, 'geen rechten');
|
|
await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]);
|
|
return { ok: true };
|
|
});
|
|
// groepsleiding aan klas koppelen/loskoppelen
|
|
// Elke staf-rol (groepsleiding, schoolbeheerder, systeemmanager) kan aan
|
|
// een klas gekoppeld worden; alleen leerlingen niet (die horen al bij één
|
|
// klas via users.class_id). Een systeemmanager is school-onafhankelijk en
|
|
// mag dus aan klassen van elke school gekoppeld worden.
|
|
app.post('/admin/classes/:id/teachers', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['classes.manage']);
|
|
const { userId, remove } = req.body ?? {};
|
|
if (!userId) return fail(reply, 400, 'gebruiker verplicht');
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [userId])).rows[0];
|
|
if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend');
|
|
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
|
|
return fail(reply, 403, 'geen rechten');
|
|
if (!['teacher', 'admin', 'super'].includes(u.role)) return fail(reply, 400, 'alleen staf kan aan een klas gekoppeld worden');
|
|
if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id))
|
|
return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen');
|
|
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]);
|
|
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen.
|
|
// Alleen schoolbeheerder (eigen school) en systeemmanager - niet
|
|
// de groepsleiding, om per ongeluk een hele klas resetten te voorkomen.
|
|
app.post('/admin/classes/:id/reset-passwords', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['classes.manage']);
|
|
const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]);
|
|
const c = r.rows[0];
|
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
|
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
|
|
return fail(reply, 403, 'geen rechten');
|
|
const pupils = (await pool.query(
|
|
"SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows;
|
|
const users = [];
|
|
for (const p of pupils) {
|
|
const pw = pupilPassword();
|
|
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
|
[await hashPassword(pw), p.id]);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
|
|
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
|
}
|
|
return { users };
|
|
});
|
|
|
|
// Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier.
|
|
app.get('/admin/genpw', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.genpw']);
|
|
const role = req.query.role === 'pupil' ? 'pupil' : 'staff';
|
|
return { password: role === 'pupil' ? pupilPassword() : staffPassword() };
|
|
});
|
|
|
|
// ---- gebruikers ---------------------------------------------------------------
|
|
// Lijst: super ziet alles (optioneel per school), admin de eigen school,
|
|
// teacher de eigen school maar leerlingen alleen uit de eigen klas(sen).
|
|
app.get('/admin/users', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
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;
|
|
} 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;
|
|
} else {
|
|
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows;
|
|
}
|
|
// 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)]);
|
|
});
|
|
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)) || [],
|
|
})) };
|
|
});
|
|
|
|
// Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school.
|
|
// teacher: alleen leerlingen in de eigen school.
|
|
app.post('/admin/users', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const b = req.body ?? {};
|
|
const role = b.role;
|
|
if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol');
|
|
const schoolId = req.user.allRoles.includes('super') ? (role === 'super' ? null : b.school) : req.user.school_id;
|
|
if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht');
|
|
const username = (b.username || '').trim();
|
|
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
|
|
try {
|
|
if (role === 'pupil') {
|
|
if (b.classId != null) {
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
|
|
if (!c || Number(c.school_id) !== Number(schoolId)) return fail(reply, 400, 'klas hoort niet bij deze school');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
|
|
}
|
|
const pw = b.password || pupilPassword();
|
|
const err = checkPassword(pw, 'pupil');
|
|
if (err) return fail(reply, 400, err);
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash)
|
|
VALUES ($1,'pupil',$2,$3,$4,$5) RETURNING *`,
|
|
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw)]);
|
|
return { user: { ...publicUser(r.rows[0]), password: pw } };
|
|
}
|
|
// groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of
|
|
// gegenereerd), ofwel de klassieke koppelcode om zelf te activeren.
|
|
if (b.password || b.generatePassword) {
|
|
const pw = b.password || staffPassword();
|
|
const err = checkPassword(pw, role);
|
|
if (err) return fail(reply, 400, err);
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, role, school_id, display_name, password_hash)
|
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
|
[username, role, schoolId, b.displayName || username, await hashPassword(pw)]);
|
|
return { user: { ...publicUser(r.rows[0]), password: pw } };
|
|
}
|
|
const code = newLinkCode();
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, role, school_id, display_name, link_code)
|
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
|
[username, role, schoolId, b.displayName || username, code]);
|
|
return { user: { ...publicUser(r.rows[0]), linkCode: code } };
|
|
} catch (e) {
|
|
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super).
|
|
app.patch('/admin/users/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
|
|
const u = r.rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen');
|
|
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
|
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
|
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
|
const b = req.body ?? {};
|
|
if (b.displayName) {
|
|
if (typeof b.displayName !== 'string' || b.displayName.trim().length > 80) return fail(reply, 400, 'ongeldige weergavenaam');
|
|
await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName.trim(), u.id]);
|
|
}
|
|
if (b.classId !== undefined && u.role === 'pupil') {
|
|
if (b.classId !== null) {
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
|
|
if (!c || Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
|
|
}
|
|
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
|
|
}
|
|
if (b.password && u.role === 'pupil') {
|
|
const err = checkPassword(b.password, 'pupil');
|
|
if (err) return fail(reply, 400, err);
|
|
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
|
[await hashPassword(b.password), u.id]);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
|
}
|
|
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
|
|
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
|
|
if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
|
|
const pw = b.password || staffPassword();
|
|
const err = checkPassword(pw, u.role);
|
|
if (err) return fail(reply, 400, err);
|
|
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
|
return { ok: true, password: pw };
|
|
}
|
|
if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
|
|
const code = newLinkCode();
|
|
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
|
|
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
|
return { ok: true, linkCode: code };
|
|
}
|
|
// rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven
|
|
// leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren
|
|
if (b.role && can(req.user.allRoles, 'users.role.change')
|
|
&& ['super', 'admin', 'teacher'].includes(b.role)
|
|
&& ['super', 'admin', 'teacher'].includes(u.role))
|
|
await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3',
|
|
[b.role, b.role === 'super' ? null : u.school_id, u.id]);
|
|
// extra functies naast de hoofdrol (bv. een teacher die ook admin-rechten krijgt) -
|
|
// alleen voor staf, zelfde recht als hoofdrol-wijziging; hoofdrol zelf telt niet
|
|
// ook nog als "extra"
|
|
if (b.extraRoles !== undefined && can(req.user.allRoles, 'users.role.change') && u.role !== 'pupil') {
|
|
const extra = [...new Set(b.extraRoles)].filter((r) => ['super', 'admin', 'teacher'].includes(r) && r !== u.role);
|
|
await pool.query('DELETE FROM user_roles WHERE user_id = $1', [u.id]);
|
|
for (const role of extra) {
|
|
await pool.query('INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING', [u.id, role]);
|
|
}
|
|
return { ok: true, extraRoles: extra };
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
app.delete('/admin/users/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
|
|
const u = r.rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen');
|
|
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
|
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
|
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
|
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// ---- toewijzingen (leerling-omgeving) ------------------------------------------
|
|
// Een leerkracht koppelt een eigen bord (via het stabiele board-id) aan een klas
|
|
// (standaard voor iedereen erin) of een individuele leerling (uitzondering, voor
|
|
// wie op een ander niveau werkt). Leerling-eigen koppeling wint van de klas-
|
|
// koppeling. Geen momentopname: het bord wordt live opgezocht in de data van de
|
|
// leerkracht, dus nieuwe woorden die de leerkracht toevoegt komen vanzelf door.
|
|
const classOwnedByTeacher = async (classId, userId) =>
|
|
!!(await pool.query('SELECT 1 FROM class_teachers WHERE class_id = $1 AND user_id = $2', [classId, userId])).rows[0];
|
|
const findBoardById = (data, boardId) => {
|
|
const folders = data?.boards?.folders || [];
|
|
for (const fo of folders) {
|
|
const bo = (fo.boards || []).find((b) => b.id === boardId);
|
|
if (bo) return bo;
|
|
}
|
|
return null;
|
|
};
|
|
const describeAssignment = async (a) => {
|
|
const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
|
|
const board = t ? findBoardById(t.data, a.board_id) : null;
|
|
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
|
|
return {
|
|
teacherId: Number(a.teacher_id),
|
|
teacherName: t ? (t.display_name || t.username) : null,
|
|
boardId: a.board_id,
|
|
boardName: board ? board.name : null,
|
|
widgetId: a.widget_id || null,
|
|
widgetType: widget ? widget.id : null,
|
|
mode: a.mode || 'werken',
|
|
updatedAt: a.updated_at,
|
|
};
|
|
};
|
|
// Gedeelde validatie voor beide PUT-routes: heel bord (widgetId null, bestaand
|
|
// gedrag) of één widget van dat bord, met modus kijken/werken. De widget moet
|
|
// echt in het opgegeven bord van de leerkracht zelf staan.
|
|
const parseShareBody = (req, reply) => {
|
|
const { boardId, widgetId, mode } = req.body ?? {};
|
|
if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId');
|
|
const board = findBoardById(req.user.data, boardId);
|
|
if (!board) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker');
|
|
const m = mode ?? 'werken';
|
|
if (m !== 'kijken' && m !== 'werken') return fail(reply, 400, 'ongeldige modus');
|
|
if (widgetId != null) {
|
|
if (typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
|
|
if (!(board.data?.widgets || []).some((w) => w.wid === widgetId)) return fail(reply, 400, 'widget onbekend op dit bord');
|
|
}
|
|
return { boardId, widgetId: widgetId ?? null, mode: m };
|
|
};
|
|
|
|
app.get('/assignments/class/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
|
const a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [c.id])).rows[0];
|
|
return { assignment: a ? await describeAssignment(a) : null };
|
|
});
|
|
app.put('/assignments/class/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const share = parseShareBody(req, reply);
|
|
if (share.error) return share;
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
|
await pool.query(
|
|
`INSERT INTO assignments (class_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (class_id) WHERE class_id IS NOT NULL
|
|
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
|
|
[c.id, req.user.id, share.boardId, share.widgetId, share.mode]);
|
|
return { ok: true };
|
|
});
|
|
app.delete('/assignments/class/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
|
await pool.query('DELETE FROM assignments WHERE class_id = $1', [c.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
const pupilAccessible = async (req, u) => {
|
|
if (!u || u.role !== 'pupil' || !sameSchool(req, u)) return false;
|
|
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) return false;
|
|
return true;
|
|
};
|
|
app.get('/assignments/pupil/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
|
const a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [u.id])).rows[0];
|
|
return { assignment: a ? await describeAssignment(a) : null };
|
|
});
|
|
app.put('/assignments/pupil/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const share = parseShareBody(req, reply);
|
|
if (share.error) return share;
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
|
await pool.query(
|
|
`INSERT INTO assignments (pupil_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (pupil_id) WHERE pupil_id IS NOT NULL
|
|
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
|
|
[u.id, req.user.id, share.boardId, share.widgetId, share.mode]);
|
|
return { ok: true };
|
|
});
|
|
app.delete('/assignments/pupil/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
|
await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
|
|
// Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen
|
|
// doorkomen zodra de leerling-pagina opnieuw ophaalt (polling).
|
|
app.get('/my/assignment', async (req, reply) => {
|
|
need(req, reply);
|
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
|
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
|
|
if (!a && req.user.class_id) {
|
|
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0];
|
|
}
|
|
if (!a) return { widgets: [], mode: 'werken', updatedAt: null };
|
|
const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
|
|
const board = t ? findBoardById(t.data, a.board_id) : null;
|
|
let widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, wid: w.wid, state: w.state }));
|
|
// één-widget-toewijzing: alleen die widget; is hij inmiddels van het bord
|
|
// verwijderd, dan een lege lijst (zelfde weergave als geen toewijzing)
|
|
if (a.widget_id) widgets = widgets.filter((w) => w.wid === a.widget_id);
|
|
return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at };
|
|
});
|
|
|
|
// ---- voortgang (leerling-omgeving) ---------------------------------------------
|
|
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
|
|
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve
|
|
// toewijzing (net als /my/assignment), nooit van de client, zodat een leerling geen
|
|
// voortgang onder een andere leerkracht kan wegschrijven.
|
|
const PROGRESS_LIMIT = 200;
|
|
const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0));
|
|
app.post('/my/progress', async (req, reply) => {
|
|
need(req, reply);
|
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
|
const { widgetId, widgetType, attempts, correct, stars } = req.body ?? {};
|
|
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
|
|
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
|
|
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
|
|
if (!a && req.user.class_id) {
|
|
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0];
|
|
}
|
|
// thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig,
|
|
// de voortgang wordt gelogd met de (eerste) gekoppelde ouder als
|
|
// "leerkracht" zodat het ouderportaal de tijdlijn direct kan tonen
|
|
if (!a && !req.user.school_id) {
|
|
const p = (await pool.query(
|
|
'SELECT parent_id FROM parent_children WHERE pupil_id = $1 ORDER BY parent_id LIMIT 1',
|
|
[req.user.id])).rows[0];
|
|
if (p) a = { teacher_id: p.parent_id, board_id: 'thuis' };
|
|
}
|
|
if (!a) return fail(reply, 400, 'geen toewijzing');
|
|
await pool.query(
|
|
`INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
|
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars)]);
|
|
return { ok: true };
|
|
});
|
|
const describeProgressEvent = (e) => ({
|
|
widgetId: e.widget_id,
|
|
widgetType: e.widget_type,
|
|
attempts: e.attempts,
|
|
correct: e.correct,
|
|
stars: e.stars,
|
|
createdAt: e.created_at,
|
|
});
|
|
// Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder
|
|
// gelden de normale staf-regels (progress.view + pupilAccessible).
|
|
const parentLinked = async (parentId, pupilId) => !!(await pool.query(
|
|
'SELECT 1 FROM parent_children WHERE parent_id = $1 AND pupil_id = $2',
|
|
[parentId, pupilId])).rows[0];
|
|
app.get('/progress/pupil/:id', async (req, reply) => {
|
|
need(req, reply);
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (req.user.role === 'parent') {
|
|
if (!(await parentLinked(req.user.id, u.id))) return fail(reply, 403, 'geen rechten');
|
|
} else {
|
|
if (!can(req.user.allRoles, 'progress.view')) return fail(reply, 403, 'geen rechten');
|
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
|
}
|
|
const rows = (await pool.query(
|
|
'SELECT * FROM progress_events WHERE pupil_id = $1 ORDER BY created_at DESC LIMIT $2',
|
|
[u.id, PROGRESS_LIMIT])).rows;
|
|
return { events: rows.map(describeProgressEvent) };
|
|
});
|
|
app.get('/progress/class/:id', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['progress.view']);
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
|
const rows = (await pool.query(
|
|
`SELECT pe.*, u.display_name, u.username FROM progress_events pe
|
|
JOIN users u ON u.id = pe.pupil_id
|
|
WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`,
|
|
[c.id, PROGRESS_LIMIT])).rows;
|
|
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
|
|
});
|
|
|
|
// ---- ouderportaal -----------------------------------------------------------
|
|
// Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school,
|
|
// beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school.
|
|
// De school houdt de regie over aanmeldverzoeken (adopteren/koppelen/afwijzen).
|
|
const needParent = (req, reply) => {
|
|
need(req, reply);
|
|
if (req.user.role !== 'parent') { reply.code(403); throw new Error('alleen voor ouders'); }
|
|
};
|
|
const USERNAME_RE = /^[a-zA-Z0-9_.-]{2,30}$/;
|
|
|
|
app.post('/auth/register-parent', {
|
|
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
|
|
}, async (req, reply) => {
|
|
const { username, password } = req.body ?? {};
|
|
const name = typeof username === 'string' ? username.trim() : '';
|
|
if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige gebruikersnaam');
|
|
const err = checkPassword(password, 'parent');
|
|
if (err) return fail(reply, 400, err);
|
|
try {
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, role, school_id, display_name, password_hash, last_login_at)
|
|
VALUES ($1,'parent',NULL,$1,$2,now()) RETURNING *`,
|
|
[name, await hashPassword(password)]);
|
|
const u = r.rows[0];
|
|
const token = await createSession(pool, u.id, u.role);
|
|
setSessionCookie(reply, token, u.role);
|
|
return { user: publicUser(u) };
|
|
} catch (e) {
|
|
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// Gekoppelde kinderen, met school-/klasnaam en eventuele aanmeldstatus.
|
|
app.get('/parent/children', async (req, reply) => {
|
|
needParent(req, reply);
|
|
const rows = (await pool.query(
|
|
`SELECT u.id, u.username, u.display_name, u.school_id, u.class_id,
|
|
s.name AS school_name, c.name AS class_name
|
|
FROM parent_children pc JOIN users u ON u.id = pc.pupil_id
|
|
LEFT JOIN schools s ON s.id = u.school_id
|
|
LEFT JOIN classes c ON c.id = u.class_id
|
|
WHERE pc.parent_id = $1 ORDER BY lower(u.username)`, [req.user.id])).rows;
|
|
const reqs = (await pool.query(
|
|
`SELECT r.pupil_id, s.name AS school_name
|
|
FROM school_link_requests r JOIN schools s ON s.id = r.school_id
|
|
WHERE r.parent_id = $1`, [req.user.id])).rows;
|
|
const pendingByPupil = new Map(reqs.map((r) => [String(r.pupil_id), r.school_name]));
|
|
return { children: rows.map((u) => ({
|
|
id: Number(u.id),
|
|
username: u.username,
|
|
displayName: u.display_name || u.username,
|
|
schoolId: u.school_id == null ? null : Number(u.school_id),
|
|
schoolName: u.school_name || null,
|
|
className: u.class_name || null,
|
|
appliedTo: pendingByPupil.get(String(u.id)) || null,
|
|
})) };
|
|
});
|
|
|
|
// Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld.
|
|
// Het gegenereerde wachtwoord wordt eenmalig teruggegeven.
|
|
app.post('/parent/children', async (req, reply) => {
|
|
needParent(req, reply);
|
|
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige naam (2-30 tekens, letters/cijfers)');
|
|
const pw = pupilPassword();
|
|
try {
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, role, school_id, display_name, password_hash)
|
|
VALUES ($1,'pupil',NULL,$1,$2) RETURNING *`,
|
|
[name, await hashPassword(pw)]);
|
|
const u = r.rows[0];
|
|
await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2)', [req.user.id, u.id]);
|
|
return { child: { ...publicUser(u), password: pw } };
|
|
} catch (e) {
|
|
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al');
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// Schoolkind koppelen met de eenmalige ouder-code die de school uitgeeft.
|
|
app.post('/parent/children/link', {
|
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
|
|
}, async (req, reply) => {
|
|
needParent(req, reply);
|
|
const { code } = req.body ?? {};
|
|
if (typeof code !== 'string' || !code.trim() || code.length > 20) return fail(reply, 400, 'ongeldige code');
|
|
const u = (await pool.query(
|
|
"SELECT * FROM users WHERE parent_code = $1 AND role = 'pupil'",
|
|
[code.trim().toUpperCase()])).rows[0];
|
|
if (!u) return fail(reply, 404, 'code onbekend of al gebruikt');
|
|
await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
|
[req.user.id, u.id]);
|
|
await pool.query('UPDATE users SET parent_code = NULL WHERE id = $1', [u.id]);
|
|
return { child: publicUser(u) };
|
|
});
|
|
|
|
// Thuiskind aanmelden bij een school: er ontstaat een aanmeldverzoek dat de
|
|
// school afhandelt; dubbel aanmelden bij dezelfde school geeft een 409.
|
|
app.post('/parent/children/:id/apply', async (req, reply) => {
|
|
needParent(req, reply);
|
|
const { schoolId } = req.body ?? {};
|
|
if (!schoolId) return fail(reply, 400, 'school verplicht');
|
|
if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'kind onbekend');
|
|
if (u.school_id != null) return fail(reply, 400, 'dit kind zit al op een school');
|
|
const s = (await pool.query('SELECT * FROM schools WHERE id = $1', [schoolId])).rows[0];
|
|
if (!s) return fail(reply, 404, 'school onbekend');
|
|
try {
|
|
await pool.query('INSERT INTO school_link_requests (school_id, parent_id, pupil_id) VALUES ($1,$2,$3)',
|
|
[s.id, req.user.id, u.id]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return fail(reply, 409, 'al aangemeld bij deze school');
|
|
throw e;
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
// Alleen de koppeling verwijderen; het kind-account zelf blijft bestaan.
|
|
app.delete('/parent/children/:id', async (req, reply) => {
|
|
needParent(req, reply);
|
|
await pool.query('DELETE FROM parent_children WHERE parent_id = $1 AND pupil_id = $2',
|
|
[req.user.id, req.params.id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Staf genereert per leerling een eenmalige ouder-code; de respons toont
|
|
// hem één keer, daarna is hij alleen nog te vernieuwen (oude vervalt).
|
|
app.post('/admin/users/:id/parent-code', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
|
const code = newLinkCode();
|
|
await pool.query('UPDATE users SET parent_code = $1 WHERE id = $2', [code, u.id]);
|
|
return { ok: true, parentCode: code };
|
|
});
|
|
|
|
// Openstaande aanmeldverzoeken voor de eigen school (super: gekozen school).
|
|
const requestSchoolId = (req) =>
|
|
req.user.allRoles.includes('super') ? req.query?.school : req.user.school_id;
|
|
app.get('/admin/link-requests', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const schoolId = requestSchoolId(req);
|
|
if (!schoolId) return { requests: [] };
|
|
const rows = (await pool.query(
|
|
`SELECT r.id, r.created_at,
|
|
k.id AS pupil_id, k.username AS pupil_username, k.display_name AS pupil_name,
|
|
o.username AS parent_username, o.display_name AS parent_name
|
|
FROM school_link_requests r
|
|
JOIN users k ON k.id = r.pupil_id
|
|
JOIN users o ON o.id = r.parent_id
|
|
WHERE r.school_id = $1 ORDER BY r.created_at`, [schoolId])).rows;
|
|
return { requests: rows.map((r) => ({
|
|
id: Number(r.id),
|
|
createdAt: r.created_at,
|
|
pupil: { id: Number(r.pupil_id), username: r.pupil_username, displayName: r.pupil_name || r.pupil_username },
|
|
parent: { username: r.parent_username, displayName: r.parent_name || r.parent_username },
|
|
})) };
|
|
});
|
|
|
|
// Afhandelen van een aanmeldverzoek:
|
|
// * adopt - het thuisaccount wordt de schoolleerling (school + klas op
|
|
// hetzelfde account; borden/voortgang/ouderkoppeling blijven).
|
|
// Bij een naamconflict in de school kan staf direct een andere
|
|
// gebruikersnaam meegeven.
|
|
// * match - de ouderkoppeling verhuist naar een bestaande schoolleerling;
|
|
// het thuisaccount wordt alleen opgeruimd als het leeg is (geen
|
|
// borddata), zodat er nooit werk verloren gaat.
|
|
// * reject - verzoek verdwijnt, verder verandert er niets.
|
|
app.post('/admin/link-requests/:id/resolve', async (req, reply) => {
|
|
need(req, reply, PERMISSIONS['users.manage']);
|
|
const r = (await pool.query('SELECT * FROM school_link_requests WHERE id = $1', [req.params.id])).rows[0];
|
|
if (!r) return fail(reply, 404, 'aanmelding onbekend');
|
|
if (!req.user.allRoles.includes('super') && Number(r.school_id) !== Number(req.user.school_id))
|
|
return fail(reply, 403, 'geen rechten');
|
|
const b = req.body ?? {};
|
|
if (b.action === 'reject') {
|
|
await pool.query('DELETE FROM school_link_requests WHERE id = $1', [r.id]);
|
|
return { ok: true };
|
|
}
|
|
if (b.action === 'adopt') {
|
|
let classId = null;
|
|
if (b.classId != null) {
|
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
|
|
if (!c || Number(c.school_id) !== Number(r.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
|
|
classId = c.id;
|
|
}
|
|
let username = null;
|
|
if (b.username != null) {
|
|
username = String(b.username).trim();
|
|
if (!USERNAME_RE.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
|
|
}
|
|
try {
|
|
await pool.query(
|
|
`UPDATE users SET school_id = $1, class_id = $2, username = COALESCE($3, username)
|
|
WHERE id = $4 AND role = 'pupil'`,
|
|
[r.school_id, classId, username, r.pupil_id]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school');
|
|
throw e;
|
|
}
|
|
// het kind hoort nu bij deze school: ook aanmeldingen bij andere
|
|
// scholen zijn daarmee vervallen
|
|
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
|
|
return { ok: true };
|
|
}
|
|
if (b.action === 'match') {
|
|
if (!b.pupilId) return fail(reply, 400, 'leerling verplicht');
|
|
const target = (await pool.query('SELECT * FROM users WHERE id = $1', [b.pupilId])).rows[0];
|
|
if (!target || target.role !== 'pupil' || Number(target.school_id) !== Number(r.school_id))
|
|
return fail(reply, 400, 'leerling hoort niet bij deze school');
|
|
await pool.query(
|
|
`INSERT INTO parent_children (parent_id, pupil_id)
|
|
SELECT parent_id, $1 FROM parent_children WHERE pupil_id = $2
|
|
ON CONFLICT DO NOTHING`, [target.id, r.pupil_id]);
|
|
const home = (await pool.query(
|
|
`SELECT (data IS NULL OR data = '{}'::jsonb) AS empty FROM users WHERE id = $1`,
|
|
[r.pupil_id])).rows[0];
|
|
if (home?.empty) {
|
|
// leeg thuisaccount opruimen (cascade ruimt koppelingen en verzoeken op)
|
|
await pool.query('DELETE FROM users WHERE id = $1', [r.pupil_id]);
|
|
} else {
|
|
// thuisaccount met werk erin blijft bestaan; alleen de verzoeken weg
|
|
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
return fail(reply, 400, 'onbekende actie');
|
|
});
|
|
|
|
// ---- gedeelde bibliotheek --------------------------------------------------
|
|
// Systeembreed (school_id NULL, gepubliceerd door de systeemmanager, voor
|
|
// iedereen zichtbaar) of per school (alleen zichtbaar binnen die school).
|
|
// Andere gebruikers KOPIËREN een item naar hun eigen collectie; er wordt
|
|
// niet live in gedeelde items gewerkt. Beheren (hernoemen/verplaatsen/
|
|
// verwijderen) mag de systeemmanager altijd, de eigenaar altijd, en de
|
|
// schoolbeheerder voor items van de eigen school.
|
|
const SHARED_KINDS = ['board', 'anchor'];
|
|
const sharedStaff = (req, reply) => {
|
|
need(req, reply);
|
|
if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); }
|
|
};
|
|
// Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij,
|
|
// alleen de schoolbeheerder en de systeemmanager nog.
|
|
const canManageShared = (req, item) =>
|
|
req.user.allRoles.includes('super')
|
|
|| (item.school_id != null && req.user.allRoles.includes('admin')
|
|
&& Number(item.school_id) === Number(req.user.school_id))
|
|
|| (Number(item.owner_id) === Number(req.user.id) && !item.readonly);
|
|
// Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd,
|
|
// de schoolbeheerder voor items van de eigen school (niet de leerkracht).
|
|
const canLockShared = (req, item) =>
|
|
req.user.allRoles.includes('super')
|
|
|| (item.school_id != null && req.user.allRoles.includes('admin')
|
|
&& Number(item.school_id) === Number(req.user.school_id));
|
|
const sharedItemVisible = (req, item) =>
|
|
req.user.allRoles.includes('super')
|
|
|| item.school_id == null
|
|
|| Number(item.school_id) === Number(req.user.school_id);
|
|
const validSharedName = (n) => typeof n === 'string' && n.trim() && n.trim().length <= 40;
|
|
const validSharedFolder = (f) => typeof f === 'string' && f.length <= 200 && !f.includes('//') && !/[\x00-\x1f]/.test(f);
|
|
const describeShared = (req, it) => ({
|
|
id: Number(it.id),
|
|
name: it.name,
|
|
folder: it.folder || '',
|
|
scope: it.school_id == null ? 'global' : 'school',
|
|
ownerName: it.owner_name || null,
|
|
readonly: !!it.readonly,
|
|
canManage: canManageShared(req, it),
|
|
canLock: canLockShared(req, it),
|
|
});
|
|
|
|
app.get('/shared/:kind', async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const rows = req.user.allRoles.includes('super')
|
|
? (await pool.query(
|
|
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
|
|
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
|
WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows
|
|
: (await pool.query(
|
|
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
|
|
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
|
WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2)
|
|
ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows;
|
|
return { items: rows.map((it) => describeShared(req, it)) };
|
|
});
|
|
app.get('/shared/:kind/:id', async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
|
|
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
|
|
return { item: { ...describeShared(req, it), data: it.data } };
|
|
});
|
|
app.post('/shared/:kind', { bodyLimit: 6 * 1024 * 1024 }, async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const { name, folder, data, scope } = req.body ?? {};
|
|
if (!validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
|
|
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige data');
|
|
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 5 * 1024 * 1024) return fail(reply, 413, 'data te groot');
|
|
let schoolId = null;
|
|
if (scope === 'global') {
|
|
need(req, reply, PERMISSIONS['shared.global.manage']);
|
|
} else if (scope === 'school') {
|
|
need(req, reply, PERMISSIONS['shared.school.manage']);
|
|
if (req.user.school_id == null) return fail(reply, 400, 'geen school gekoppeld aan dit account');
|
|
schoolId = req.user.school_id;
|
|
} else return fail(reply, 400, 'ongeldige scope');
|
|
const r = await pool.query(
|
|
`INSERT INTO shared_library (school_id, owner_id, kind, folder, name, data)
|
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
|
[schoolId, req.user.id, kind, folder || '', name.trim(), data]);
|
|
return { ok: true, id: Number(r.rows[0].id) };
|
|
});
|
|
// map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map
|
|
// op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de
|
|
// gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door
|
|
// wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager)
|
|
app.patch('/shared/:kind/folder', async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const { scope, from, to, readonly } = req.body ?? {};
|
|
if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope');
|
|
if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map');
|
|
if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen');
|
|
if (to !== undefined && (!validSharedFolder(to) || !to)) return fail(reply, 400, 'ongeldige map');
|
|
const rows = scope === 'global'
|
|
? (await pool.query(
|
|
`SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`,
|
|
[kind, from, from + '/%'])).rows
|
|
: (await pool.query(
|
|
`SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`,
|
|
[kind, req.user.school_id, from, from + '/%'])).rows;
|
|
if (to !== undefined){
|
|
if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map');
|
|
await pool.query(
|
|
`UPDATE shared_library SET folder = $1 || substr(folder, $2)
|
|
WHERE id = ANY($3)`,
|
|
[to, from.length + 1, rows.map((it) => it.id)]);
|
|
}
|
|
if (typeof readonly === 'boolean'){
|
|
if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
|
|
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]);
|
|
}
|
|
return { ok: true };
|
|
});
|
|
app.patch('/shared/:kind/:id', async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
|
|
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
|
|
const { name, folder, readonly } = req.body ?? {};
|
|
if (name !== undefined || folder !== undefined){
|
|
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
|
|
if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
|
|
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
|
|
if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]);
|
|
if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]);
|
|
}
|
|
if (readonly !== undefined){
|
|
if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde');
|
|
if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
|
|
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]);
|
|
}
|
|
return { ok: true };
|
|
});
|
|
app.delete('/shared/:kind/:id', async (req, reply) => {
|
|
sharedStaff(req, reply);
|
|
const { kind } = req.params;
|
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
|
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
|
|
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
|
|
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
|
|
await pool.query('DELETE FROM shared_library WHERE id = $1', [it.id]);
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
// Eerste super-beheerder aanmaken als die nog niet bestaat.
|
|
export async function bootstrapSuper(pool, log) {
|
|
const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'");
|
|
if (r.rows[0].n > 0) return;
|
|
const username = process.env.SUPER_USER || 'beheerder';
|
|
const password = process.env.SUPER_PASS;
|
|
if (!password) throw new Error('SUPER_PASS is verplicht bij het aanmaken van de eerste super-beheerder');
|
|
const passwordError = checkPassword(password, 'super');
|
|
if (passwordError) throw new Error(`ongeldige SUPER_PASS: ${passwordError}`);
|
|
await pool.query(
|
|
`INSERT INTO users (username, role, display_name, password_hash)
|
|
VALUES ($1, 'super', $1, $2)`,
|
|
[username, await hashPassword(password)]);
|
|
log.warn(`Eerste super-beheerder aangemaakt: ${username}. Het wachtwoord wordt niet gelogd.`);
|
|
}
|