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

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.env*
*.log
.DS_Store

41
backend/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# ╔══════════════════════════════════════════════╗
# ║ Backend Dockerfile multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps (incl. native build tools) ─────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload via --watch) ─
FROM deps AS development
COPY . .
EXPOSE 3001
CMD ["node", "--watch", "src/server.js"]
# ── Stage 3: productie-deps (apart gebouwd) ───────
FROM deps AS prod-deps
RUN npm prune --omit=dev
# ── Stage 4: production (minimale image) ──────────
FROM node:22-alpine AS production
LABEL maintainer="VibeFinance"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
COPY src/ ./src/
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "src/server.js"]

27
backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "vibefinance-backend",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"lint": "eslint src --ext .js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"eslint": "^8.57.0"
}
}

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]);
},
};