Files
rikx-beheer/lib/db.ts
T
remco-vermeer 53bf667f54 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>
2026-10-08 14:42:22 +02:00

221 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DatabaseSync } from "node:sqlite";
import path from "path";
const DB_PATH =
process.env.DB_PATH || path.join(process.cwd(), "data", "app.db");
let _db: DatabaseSync | null = null;
export function getDb(): DatabaseSync {
if (!_db) {
_db = new DatabaseSync(DB_PATH);
_db.exec("PRAGMA journal_mode = WAL");
_db.exec("PRAGMA foreign_keys = ON");
migrate(_db);
}
return _db;
}
function migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
dropLegacyQuoteTables(db);
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'aanvraag',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
-- Rikx Ronde: investeringsronde (bv. van een gemeente) waarin projecten meedoen.
-- Rikx waarde en Rikx prijs gelden voor alle projecten in de ronde.
CREATE TABLE IF NOT EXISTS rikx_rondes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL UNIQUE,
rikx_waarde REAL,
rikx_prijs REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Module 4: experts zijn gebruikers met role = 'expert'.
-- Rondes waarvan een expert de projecten in waardebepaling ziet
CREATE TABLE IF NOT EXISTS expert_rondes (
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ronde_id INTEGER NOT NULL REFERENCES rikx_rondes(id) ON DELETE CASCADE,
PRIMARY KEY (expert_id, ronde_id)
);
-- Uitnodigingen: alleen de SHA-256-hash van het token wordt bewaard
CREATE TABLE IF NOT EXISTS expert_uitnodigingen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Beoordeling van een project door een expert; Impactscore M = gemiddelde over de experts
CREATE TABLE IF NOT EXISTS expert_beoordelingen (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
duur_impact REAL,
levensdomeinen REAL,
basissituatie REAL,
bonus_malus_advies TEXT,
toelichting TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, expert_id)
);
-- Oude, handmatig ingevulde expertscores (expert 1–3). Niet meer gebruikt sinds de expertbeoordelingen;
-- de tabel blijft bestaan omdat hij in productie al gegevens kan bevatten.
CREATE TABLE IF NOT EXISTS project_expert_scores (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
expert INTEGER NOT NULL,
duur_impact REAL,
levensdomeinen REAL,
basissituatie REAL,
PRIMARY KEY (project_id, expert)
);
`);
// Velden uit _doc/Rikx rekenmethode uitleg.xlsx
// Stap "Project plan" (ingevuld door de sociaal ondernemer)
addColumnIfMissing(db, "projects", "projectkosten", "REAL");
addColumnIfMissing(db, "projects", "deelnemers_werk", "INTEGER");
addColumnIfMissing(db, "projects", "deelnemers_skills", "INTEGER");
addColumnIfMissing(db, "projects", "financiering_extern_bedrag", "REAL");
addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL");
// Overige project plan-velden als JSON; definities in lib/projectPlan.ts
addColumnIfMissing(db, "projects", "project_plan", "TEXT");
// Tab "Rikx calculatie". rikx_calculatie_aan is de oude schuif (niet meer gebruikt;
// de tab is nu zichtbaar vanaf status waardebepaling) en blijft staan omdat hij al in productie bestaat.
addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0");
addColumnIfMissing(db, "projects", "rekenmethode", "TEXT NOT NULL DEFAULT 'arbeid'");
addColumnIfMissing(db, "projects", "bonus_malus", "TEXT");
// Beoordeling onderaan de tab Rikx calculatie: 'goedgekeurd', 'afgekeurd' of NULL (nog niet beoordeeld)
addColumnIfMissing(db, "projects", "beoordeling", "TEXT");
addColumnIfMissing(db, "projects", "beoordeling_reden", "TEXT");
// Impactmaker: de sociaal ondernemer (accounts.id in de platformdatabase; geen foreign key, andere database)
addColumnIfMissing(db, "projects", "impactmaker_account_id", "INTEGER");
addColumnIfMissing(db, "projects", "ronde_id", "INTEGER REFERENCES rikx_rondes(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_ronde ON projects(ronde_id)");
}
function addColumnIfMissing(db: DatabaseSync, table: string, column: string, definition: string) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!columns.some((c) => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
// Ruimt de tabellen van het oude offerte-portaal op (clients, projects, quotes,
// quote_items). Lege tabellen worden verwijderd; bevat een tabel onverwacht
// gegevens, dan wordt die hernoemd naar legacy_<naam> zodat er niets verloren gaat.
function dropLegacyQuoteTables(db: DatabaseSync) {
const projectColumns = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
if (!projectColumns.some((c) => c.name === "client_id")) return;
const tables = ["quote_items", "quotes", "projects", "clients"];
const existing = new Set(
(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]).map(
(t) => t.name
)
);
db.exec("BEGIN");
try {
for (const table of tables) {
if (!existing.has(table)) continue;
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
if (n === 0) {
db.exec(`DROP TABLE ${table}`);
} else {
console.warn(`Oude tabel ${table} bevat ${n} rijen; hernoemd naar legacy_${table}`);
db.exec(`ALTER TABLE ${table} RENAME TO legacy_${table}`);
}
}
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}
export type Project = {
id: number;
name: string;
status: string;
projectkosten: number | null;
deelnemers_werk: number | null;
deelnemers_skills: number | null;
financiering_extern_bedrag: number | null;
financiering_extern_pct: number | null;
project_plan: string | null;
rikx_calculatie_aan: number;
rekenmethode: string;
bonus_malus: string | null;
beoordeling: string | null;
beoordeling_reden: string | null;
impactmaker_account_id: number | null;
ronde_id: number | null;
created_at: string;
updated_at: string;
};
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
export type ProjectListItem = Project & { ronde_label: string | null };
export type ExpertBeoordeling = {
project_id: number;
expert_id: number;
duur_impact: number | null;
levensdomeinen: number | null;
basissituatie: number | null;
bonus_malus_advies: string | null;
toelichting: string | null;
created_at: string;
updated_at: string;
};
// Oude handmatige scores (niet meer gebruikt)
export type ProjectExpertScore = {
project_id: number;
expert: number;
duur_impact: number | null;
levensdomeinen: number | null;
basissituatie: number | null;
};
export type RikxRonde = {
id: number;
label: string;
rikx_waarde: number | null;
rikx_prijs: number | null;
created_at: string;
updated_at: string;
};
export function listRikxRondes(): RikxRonde[] {
return (
getDb().prepare("SELECT * FROM rikx_rondes ORDER BY created_at DESC, id DESC").all() as RikxRonde[]
).map((r) => ({ ...r }));
}