Files
remco-vermeer 9be692974e Voeg CMS voor de Publieke Website toe (overgenomen van rikx-org)
Pagina-editor, hoofdmenu, SEO en media onder Publieke Website → Pagina's
(/website), los van het CMS in rikx-org: eigen tabellen in de beheerdatabase.
Blokken (hero, tekstblok, testimonial, voordelenlijst, call-to-action) met
Quill, NL/EN/DE met terugval op NL, en afbeeldingsupload naar MEDIA_DIR met
een openbare /media-route (CSP-sandbox).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:27:12 +02:00

264 lines
9.6 KiB
TypeScript
Raw Permalink 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)");
// Publieke Website (CMS), overgenomen van rikx-org; functies in lib/cms.ts.
// NL staat in pages/content_blocks zelf, EN/DE in de *_translations-tabellen.
db.exec(`
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
meta_title TEXT NOT NULL DEFAULT '',
meta_description TEXT NOT NULL DEFAULT '',
og_image TEXT,
canonical_url TEXT,
noindex INTEGER NOT NULL DEFAULT 0,
nav_label TEXT,
nav_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS content_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
block_key TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('hero','richtext','testimonial','benefits','cta')),
label TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (page_slug, block_key)
);
CREATE TABLE IF NOT EXISTS page_translations (
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en','de')),
meta_title TEXT NOT NULL DEFAULT '',
meta_description TEXT NOT NULL DEFAULT '',
nav_label TEXT NOT NULL DEFAULT '',
PRIMARY KEY (page_slug, lang)
);
CREATE TABLE IF NOT EXISTS block_translations (
block_id INTEGER NOT NULL REFERENCES content_blocks(id) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en','de')),
content TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (block_id, lang)
);
`);
}
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 }));
}