first commit

This commit is contained in:
2026-04-16 10:22:13 +02:00
commit 2b72f306ff
55 changed files with 10732 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import "dotenv/config";
export const config = {
NODE_ENV: process.env.NODE_ENV || "development",
PORT: parseInt(process.env.PORT || "3001", 10),
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost",
JWT_SECRET: process.env.JWT_SECRET || "change-me-in-production",
JWT_EXPIRES: process.env.JWT_EXPIRES || "7d",
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
};

View File

@@ -0,0 +1,30 @@
export const INITIAL_DATA = {
eigenVermogen: {
doel: 500000,
categories: [
{ id: "c1", naam: "Aandelen", color: "#3b82f6", items: [] },
{ id: "c2", naam: "Crypto", color: "#8b5cf6", items: [] },
{ id: "c3", naam: "Commodities", color: "#f59e0b", items: [] },
{ id: "c4", naam: "Vastgoed", color: "#10b981", items: [] },
{ id: "c5", naam: "Crowdfunding", color: "#06b6d4", items: [] },
{ id: "c6", naam: "Te Besteden", color: "#64748b", items: [] },
],
},
schuld: {
initieleSchuld: 303000,
schuldHistory: [],
blokken: [
{ id: "s1", naam: "Hypotheek", color: "#ef4444", items: [
{ id: "s1i1", naam: "Leningdeel 1", waarde: 0 },
{ id: "s1i2", naam: "Leningdeel 2", waarde: 0 },
{ id: "s1i3", naam: "Leningdeel 3", waarde: 0 },
{ id: "s1i4", naam: "Leningdeel 4", waarde: 0 },
]},
{ id: "s2", naam: "Belastingdienst", color: "#f97316", items: [
{ id: "s2i1", naam: "Kinderopvangtoeslag", waarde: 0 },
{ id: "s2i2", naam: "Sub-categorie 2", waarde: 0 },
]},
],
},
evVoortgang: [],
};

33
backend/src/db.js Normal file
View File

@@ -0,0 +1,33 @@
import pg from "pg";
import { config } from "./config/index.js";
const { Pool } = pg;
export const db = new Pool({
connectionString: config.DATABASE_URL,
});
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
naam TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
wachtwoord TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'Viewer',
actief BOOLEAN NOT NULL DEFAULT true,
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL
);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
`);

9
backend/src/logger.js Normal file
View File

@@ -0,0 +1,9 @@
import pino from "pino";
import { config } from "./config/index.js";
export const logger = pino({
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});

View File

@@ -0,0 +1,22 @@
import jwt from "jsonwebtoken";
import { config } from "../config/index.js";
export function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Niet ingelogd." });
}
try {
req.user = jwt.verify(header.slice(7), config.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Sessie verlopen. Log opnieuw in." });
}
}
export function requireAdmin(req, res, next) {
if (req.user?.rol !== "Admin") {
return res.status(403).json({ error: "Geen toegang." });
}
next();
}

View File

@@ -0,0 +1,6 @@
export function errorHandler(err, _req, res, _next) {
const status = err.status || err.statusCode || 500;
const message = status < 500 ? err.message : "Er is een interne fout opgetreden.";
if (status >= 500) console.error("[ERROR]", err);
res.status(status).json({ error: message });
}

135
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,135 @@
import { Router } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { config } from "../config/index.js";
import { authenticate } from "../middleware/auth.js";
import { userStore } from "../stores/userStore.js";
import { db } from "../db.js";
const router = Router();
/** POST /api/auth/register */
router.post("/register", async (req, res, next) => {
try {
const { naam, email, wachtwoord } = req.body;
if (!naam || !email || !wachtwoord)
return res.status(400).json({ error: "Naam, e-mail en wachtwoord zijn verplicht." });
if (wachtwoord.length < 8)
return res.status(400).json({ error: "Wachtwoord moet minimaal 8 tekens zijn." });
const existing = await userStore.findByEmail(email.toLowerCase().trim());
if (existing)
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
const { rows: countRows } = await db.query("SELECT COUNT(*) FROM users");
const isFirst = parseInt(countRows[0].count) === 0;
const hash = await bcrypt.hash(wachtwoord, 12);
const user = await userStore.create({
naam: naam.trim(),
email: email.toLowerCase().trim(),
wachtwoord: hash,
rol: isFirst ? "Admin" : "Viewer",
actief: true,
});
const token = jwt.sign(
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
res.status(201).json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: null } });
} catch (err) { next(err); }
});
/** POST /api/auth/login */
router.post("/login", async (req, res, next) => {
try {
const { email, wachtwoord } = req.body;
if (!email || !wachtwoord)
return res.status(400).json({ error: "E-mail en wachtwoord zijn verplicht." });
const user = await userStore.findByEmail(email.toLowerCase().trim());
if (!user || !await bcrypt.compare(wachtwoord, user.wachtwoord))
return res.status(401).json({ error: "Ongeldig e-mailadres of wachtwoord." });
if (!user.actief)
return res.status(403).json({ error: "Account is inactief." });
const token = jwt.sign(
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
const fullUser = await userStore.findById(user.id);
res.json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: fullUser?.avatar ?? null } });
} catch (err) { next(err); }
});
/** GET /api/auth/me */
router.get("/me", authenticate, async (req, res, next) => {
try {
const user = await userStore.findById(req.user.sub);
res.json({ user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: user?.avatar ?? null } });
} catch (err) { next(err); }
});
/** PATCH /api/auth/me — update naam / email */
router.patch("/me", authenticate, async (req, res, next) => {
try {
const { naam, email } = req.body;
if (!naam?.trim() || !email?.trim())
return res.status(400).json({ error: "Naam en e-mail zijn verplicht." });
const conflict = await userStore.findByEmail(email.toLowerCase().trim());
if (conflict && conflict.id !== req.user.sub)
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
const updated = await userStore.update(req.user.sub, { naam: naam.trim(), email: email.toLowerCase().trim() });
const token = jwt.sign(
{ sub: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
res.json({ token, user: { id: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol, avatar: updated.avatar ?? null } });
} catch (err) { next(err); }
});
/** POST /api/auth/me/password — wijzig wachtwoord */
router.post("/me/password", authenticate, async (req, res, next) => {
try {
const { huidig, nieuw: nieuwWw } = req.body;
if (!huidig || !nieuwWw)
return res.status(400).json({ error: "Huidig en nieuw wachtwoord zijn verplicht." });
if (nieuwWw.length < 8)
return res.status(400).json({ error: "Nieuw wachtwoord moet minimaal 8 tekens zijn." });
const user = await userStore.findById(req.user.sub);
if (!user || !await bcrypt.compare(huidig, user.wachtwoord))
return res.status(401).json({ error: "Huidig wachtwoord is onjuist." });
const hash = await bcrypt.hash(nieuwWw, 12);
await userStore.update(req.user.sub, { wachtwoord: hash });
res.json({ message: "Wachtwoord gewijzigd." });
} catch (err) { next(err); }
});
/** PATCH /api/auth/me/avatar — sla avatar op als base64 */
router.patch("/me/avatar", authenticate, async (req, res, next) => {
try {
const { avatar } = req.body;
// avatar mag null zijn (verwijderen) of een base64 data-URL
if (avatar !== null && avatar !== undefined && typeof avatar !== "string")
return res.status(400).json({ error: "Ongeldig avatar formaat." });
await userStore.update(req.user.sub, { avatar: avatar ?? null });
res.json({ avatar: avatar ?? null });
} catch (err) { next(err); }
});
/** POST /api/auth/logout */
router.post("/logout", authenticate, (_req, res) => res.json({ message: "Uitgelogd." }));
export default router;

View File

@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticate } from "../middleware/auth.js";
import { dataStore } from "../stores/dataStore.js";
const router = Router();
router.use(authenticate);
router.get("/", async (req, res, next) => { try { res.json(await dataStore.load(req.user.sub)); } catch (e) { next(e); } });
router.put("/", async (req, res, next) => { try { res.json(await dataStore.save(req.user.sub, req.body)); } catch (e) { next(e); } });
router.delete("/", async (req, res, next) => { try { await dataStore.reset(req.user.sub); res.json({ message: "Reset." }); } catch (e) { next(e); } });
export default router;

View File

@@ -0,0 +1,7 @@
import { Router } from "express";
const router = Router();
const START = Date.now();
router.get("/", (_req, res) => {
res.json({ status: "ok", uptime: Math.floor((Date.now() - START) / 1000) });
});
export default router;

View File

@@ -0,0 +1,42 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { authenticate, requireAdmin } from "../middleware/auth.js";
import { userStore } from "../stores/userStore.js";
const router = Router();
router.use(authenticate);
const safe = ({ wachtwoord, ...u }) => ({ ...u, aangemaakt: u.aangemaakt ?? null });
router.get("/", requireAdmin, async (_req, res, next) => {
try { res.json((await userStore.list()).map(safe)); } catch (e) { next(e); }
});
router.post("/", requireAdmin, async (req, res, next) => {
try {
const { naam, email, wachtwoord, rol = "Viewer" } = req.body;
if (!naam || !email || !wachtwoord) return res.status(400).json({ error: "Naam, e-mail en wachtwoord zijn verplicht." });
const hash = await bcrypt.hash(wachtwoord, 12);
const u = await userStore.create({ naam, email: email.toLowerCase(), wachtwoord: hash, rol, actief: true });
res.status(201).json(safe(u));
} catch (e) { next(e); }
});
router.patch("/:id", requireAdmin, async (req, res, next) => {
try {
const patch = { ...req.body };
if (patch.wachtwoord) {
patch.wachtwoord = await bcrypt.hash(patch.wachtwoord, 12);
}
const u = await userStore.update(req.params.id, patch);
if (!u) return res.status(404).json({ error: "Niet gevonden." });
res.json(safe(u));
} catch (e) { next(e); }
});
router.delete("/:id", requireAdmin, async (req, res, next) => {
try { await userStore.remove(req.params.id); res.json({ message: "Verwijderd." }); }
catch (e) { next(e); }
});
export default router;

61
backend/src/server.js Normal file
View File

@@ -0,0 +1,61 @@
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import cookieParser from "cookie-parser";
import pino from "pino";
import pinoHttp from "pino-http";
import { config } from "./config/index.js";
import authRouter from "./routes/auth.js";
import dataRouter from "./routes/data.js";
import usersRouter from "./routes/users.js";
import healthRouter from "./routes/health.js";
import { errorHandler } from "./middleware/errorHandler.js";
export const logger = pino({
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});
const app = express();
// ── Security ──────────────────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
app.set("trust proxy", 1);
// ── Rate limiting ─────────────────────────────────────────────────────────────
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
// ── Parsing ───────────────────────────────────────────────────────────────────
app.use(express.json({ limit: "2mb" }));
app.use(cookieParser());
// ── Logging ───────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") {
app.use(pinoHttp({ logger }));
}
// ── Routes ────────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter);
app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter);
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ─────────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") {
app.listen(config.PORT, () => {
logger.info(`VibeFinance API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`);
});
}
export default app;

View File

@@ -0,0 +1,22 @@
import { db } from "../db.js";
import { INITIAL_DATA } from "../constants/initialData.js";
export const dataStore = {
async load(userId) {
const { rows } = await db.query("SELECT data FROM user_data WHERE user_id = $1", [userId]);
return rows[0] ? JSON.parse(rows[0].data) : structuredClone(INITIAL_DATA);
},
async save(userId, payload) {
await db.query(
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
[userId, JSON.stringify(payload)]
);
return payload;
},
async reset(userId) {
await db.query(
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
[userId, JSON.stringify(structuredClone(INITIAL_DATA))]
);
},
};

View File

@@ -0,0 +1,43 @@
/**
* PostgreSQL-backed user store.
*/
import { db } from "../db.js";
const toUser = (row) => row ? { ...row } : null;
export const userStore = {
async list() {
const { rows } = await db.query("SELECT * FROM users");
return rows.map(toUser);
},
async findByEmail(email) {
const { rows } = await db.query("SELECT * FROM users WHERE email = $1", [email]);
return toUser(rows[0]);
},
async findById(id) {
const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]);
return toUser(rows[0]);
},
async create(data) {
const u = { id: `u${Date.now()}`, ...data };
await db.query(
"INSERT INTO users (id, naam, email, wachtwoord, rol, actief) VALUES ($1, $2, $3, $4, $5, $6)",
[u.id, u.naam, u.email, u.wachtwoord, u.rol ?? "Viewer", u.actief ?? true]
);
return u;
},
async update(id, patch) {
const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]);
const user = toUser(rows[0]);
if (!user) return null;
const u = { ...user, ...patch };
await db.query(
"UPDATE users SET naam=$1, email=$2, wachtwoord=$3, rol=$4, actief=$5, avatar=$6 WHERE id=$7",
[u.naam, u.email, u.wachtwoord, u.rol, u.actief, u.avatar ?? null, id]
);
return u;
},
async remove(id) {
await db.query("DELETE FROM users WHERE id = $1", [id]);
},
};