9be692974e
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>
264 lines
9.6 KiB
TypeScript
264 lines
9.6 KiB
TypeScript
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 }));
|
||
}
|