Voeg Module 4 experts, waardebepalingspagina en projectverbeteringen toe

Experts (Module 4)
- Pagina Experts (CRM): experts uitnodigen per e-mail (zonder SMTP wordt de
  link getoond), rondes koppelen, opnieuw uitnodigen, verwijderen en
  "Inloggen als" (alleen lezen) om mee te kijken
- Uitnodigingspagina waar een expert zijn wachtwoord instelt (token 7 dagen
  geldig, eenmalig, alleen als hash opgeslagen)
- Expertpagina: projecten in waardebepaling uit de eigen rondes, project plan
  lezen en eigen beoordeling geven (3 scores, advies Bonus/Malus, toelichting)
- Rollen admin/expert: alle beheerpagina's en -acties controleren de rol
- Rikx calculatie gebruikt de beoordelingen van de experts in plaats van de
  handmatige expertkolommen; experts koppelen ook vanaf de rondepagina

Waardebepaling projecten
- Nieuwe pagina met projecten in waardebepaling op kosten/impact (laag naar
  hoog), filter op ronde, Rikx Waarde-veld dat live doorrekent en opslaat in
  de ronde, en een 75%-lijn bij een gekozen ronde

Projecten
- Rikx calculatie als tab, zichtbaar vanaf status Waardebepaling, met keuze
  rekenmethode (Arbeid; Jeugd nog niet beschikbaar) en beoordeling
  goedgekeurd/afgekeurd met reden
- Impactmaker gekoppeld aan een sociaal ondernemer
- Opslaan-knop bovenaan; ongeldige velden op een verborgen tab worden getoond
- Project toevoegen maakt direct een project aan; aparte pagina verwijderd
- Waarderingssystematiek Rikx juni 2023 toegevoegd aan _doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:42:22 +02:00
parent 9f0bd87c40
commit 53bf667f54
41 changed files with 2075 additions and 357 deletions
+134
View File
@@ -0,0 +1,134 @@
import { createHash, randomBytes } from "crypto";
import { getDb, type ExpertBeoordeling, type Project } from "./db";
// Module 4: experts zijn gebruikers (users.role = 'expert') die per Rikx ronde projecten beoordelen.
export type Expert = {
id: number;
name: string;
email: string;
actief: boolean; // heeft een wachtwoord ingesteld via de uitnodiging
rondeIds: number[];
aantalBeoordelingen: number;
created_at: string;
};
const INVITE_DAGEN = 7;
export function listExperts(): Expert[] {
const db = getDb();
const rows = db
.prepare(
`SELECT u.id, u.name, u.email, u.password_hash, u.created_at,
(SELECT COUNT(*) FROM expert_beoordelingen b WHERE b.expert_id = u.id) AS aantal
FROM users u WHERE u.role = 'expert' ORDER BY u.name COLLATE NOCASE`
)
.all() as { id: number; name: string; email: string; password_hash: string; created_at: string; aantal: number }[];
const rondes = db.prepare("SELECT expert_id, ronde_id FROM expert_rondes").all() as {
expert_id: number;
ronde_id: number;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
email: r.email,
actief: r.password_hash.includes(":"),
rondeIds: rondes.filter((x) => x.expert_id === r.id).map((x) => x.ronde_id),
aantalBeoordelingen: r.aantal,
created_at: r.created_at,
}));
}
export function getExpert(id: number): Expert | undefined {
return listExperts().find((e) => e.id === id);
}
export function getExpertRondeIds(expertId: number): number[] {
return (
getDb().prepare("SELECT ronde_id FROM expert_rondes WHERE expert_id = ?").all(expertId) as { ronde_id: number }[]
).map((r) => r.ronde_id);
}
export function setExpertRondes(expertId: number, rondeIds: number[]) {
const db = getDb();
db.prepare("DELETE FROM expert_rondes WHERE expert_id = ?").run(expertId);
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
for (const id of rondeIds) insert.run(expertId, id);
}
// Koppeling vanaf de kant van de ronde (pagina Rikx rondes); alleen bestaande experts tellen mee
export function setRondeExperts(rondeId: number, expertIds: number[]) {
const db = getDb();
const experts = new Set(
(db.prepare("SELECT id FROM users WHERE role = 'expert'").all() as { id: number }[]).map((u) => u.id)
);
db.prepare("DELETE FROM expert_rondes WHERE ronde_id = ?").run(rondeId);
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
for (const id of expertIds) if (experts.has(id)) insert.run(id, rondeId);
}
// Projecten die een expert ziet: status Waardebepaling, in een van zijn rondes
export function listProjectenVoorExpert(expertId: number): (Project & { ronde_label: string })[] {
const rondeIds = getExpertRondeIds(expertId);
if (rondeIds.length === 0) return [];
return (
getDb()
.prepare(
`SELECT p.*, r.label AS ronde_label FROM projects p JOIN rikx_rondes r ON r.id = p.ronde_id
WHERE p.status = 'waardebepaling' AND p.ronde_id IN (${rondeIds.map(() => "?").join(",")})
ORDER BY r.label COLLATE NOCASE, p.name COLLATE NOCASE`
)
.all(...rondeIds) as (Project & { ronde_label: string })[]
).map((p) => ({ ...p }));
}
export function magBeoordelen(expertId: number, projectId: number): boolean {
return listProjectenVoorExpert(expertId).some((p) => p.id === projectId);
}
// Beoordelingen van alle experts voor de gegeven projecten, met de naam van de expert
export function listBeoordelingen(projectIds: number[]): (ExpertBeoordeling & { expert_name: string })[] {
if (projectIds.length === 0) return [];
return (
getDb()
.prepare(
`SELECT b.*, u.name AS expert_name FROM expert_beoordelingen b
JOIN users u ON u.id = b.expert_id
WHERE b.project_id IN (${projectIds.map(() => "?").join(",")})
ORDER BY u.name COLLATE NOCASE`
)
.all(...projectIds) as (ExpertBeoordeling & { expert_name: string })[]
).map((b) => ({ ...b }));
}
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
// Maakt een nieuwe uitnodiging; eerdere, ongebruikte uitnodigingen van deze gebruiker vervallen.
export function createInvite(userId: number): string {
const db = getDb();
db.prepare("DELETE FROM expert_uitnodigingen WHERE user_id = ? AND used_at IS NULL").run(userId);
const token = randomBytes(32).toString("base64url");
db.prepare(
`INSERT INTO expert_uitnodigingen (user_id, token_hash, expires_at)
VALUES (?, ?, datetime('now', '+${INVITE_DAGEN} days'))`
).run(userId, hashToken(token));
return token;
}
// Geldige (niet verlopen, niet gebruikte) uitnodiging bij een token, met de bijbehorende expert
export function findInvite(token: string) {
return getDb()
.prepare(
`SELECT i.id, i.user_id, u.name, u.email FROM expert_uitnodigingen i
JOIN users u ON u.id = i.user_id
WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires_at > datetime('now') AND u.role = 'expert'`
)
.get(hashToken(token)) as { id: number; user_id: number; name: string; email: string } | undefined;
}
export function markInviteUsed(inviteId: number) {
getDb().prepare("UPDATE expert_uitnodigingen SET used_at = datetime('now') WHERE id = ?").run(inviteId);
}
export const UITNODIGING_GELDIG_DAGEN = INVITE_DAGEN;
export const MIN_WACHTWOORD_LENGTE = 8;