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:
+134
@@ -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;
|
||||
Reference in New Issue
Block a user