Initialize API and Web application structure and configuration

This commit is contained in:
2026-06-12 10:01:10 +02:00
parent 585443ebf0
commit 51ec5e08a7
9 changed files with 467 additions and 27 deletions

34
.gitignore vendored
View File

@@ -1,28 +1,8 @@
*.log
*.swp
*.tmp
.cache
.git
.mypy_cache
.next
.nuxt
.parcel-cache
.pytest_cache
.turbo
.venv
__pycache__
build
coverage
dist
dist-tauri
logs
node_modules
out
output
src-tauri/gen
src-tauri/target
target
temp
tmp
tmp*
venv
dist
.env
*.db
*.db-shm
*.db-wal
.DS_Store
coverage

34
apps/api/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@mitwirkung/api",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.25.28"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.15.21",
"supertest": "^7.1.1",
"@types/supertest": "^6.0.3",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
}
}

156
apps/api/src/app.ts Normal file
View File

@@ -0,0 +1,156 @@
import bcrypt from "bcryptjs";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import { z } from "zod";
import { attachUser, requireUser, signIn, signOut } from "./auth.js";
import { db, initializeDatabase } from "./db.js";
import type { AuthedRequest } from "./types.js";
initializeDatabase();
export const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: "250kb" }));
app.use(cookieParser());
app.use(attachUser);
const authSchema = z.object({
email: z.string().email("Bitte gib eine gültige E-Mail-Adresse ein.").toLowerCase(),
password: z.string().min(8, "Das Passwort muss mindestens 8 Zeichen lang sein."),
});
app.post("/api/auth/register", async (req, res) => {
const parsed = authSchema.extend({ name: z.string().trim().min(2).max(80) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ message: parsed.error.issues[0]?.message });
const { name, email, password } = parsed.data;
const exists = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (exists) return res.status(409).json({ message: "Diese E-Mail-Adresse ist bereits registriert." });
const result = db
.prepare("INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)")
.run(name, email, await bcrypt.hash(password, 10));
signIn(res, Number(result.lastInsertRowid));
return res.status(201).json({ user: { id: result.lastInsertRowid, name, email } });
});
app.post("/api/auth/login", async (req, res) => {
const parsed = authSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ message: "E-Mail oder Passwort ist ungültig." });
const user = db
.prepare("SELECT id, name, email, password_hash FROM users WHERE email = ?")
.get(parsed.data.email) as { id: number; name: string; email: string; password_hash: string } | undefined;
if (!user || !(await bcrypt.compare(parsed.data.password, user.password_hash))) {
return res.status(401).json({ message: "E-Mail oder Passwort ist ungültig." });
}
signIn(res, user.id);
return res.json({ user: { id: user.id, name: user.name, email: user.email } });
});
app.post("/api/auth/logout", (_req, res) => {
signOut(res);
res.status(204).end();
});
app.get("/api/auth/me", (req: AuthedRequest, res) => {
res.json({ user: req.user || null });
});
app.get("/api/topics", (_req, res) => {
const topics = db.prepare(`
SELECT t.*, COUNT(b.id) AS billCount
FROM topics t LEFT JOIN bills b ON b.topic_id = t.id
GROUP BY t.id ORDER BY t.name
`).all();
res.json({ topics });
});
const billSelect = `
SELECT b.id, b.title, b.summary, b.problem, b.solution, b.impact, b.status,
b.created_at AS createdAt, b.deadline,
u.id AS authorId, u.name AS authorName,
t.id AS topicId, t.name AS topicName, t.slug AS topicSlug, t.color AS topicColor,
SUM(CASE WHEN v.value = 'yes' THEN 1 ELSE 0 END) AS yesCount,
SUM(CASE WHEN v.value = 'no' THEN 1 ELSE 0 END) AS noCount,
SUM(CASE WHEN v.value = 'abstain' THEN 1 ELSE 0 END) AS abstainCount,
COUNT(v.user_id) AS voteCount
FROM bills b
JOIN users u ON u.id = b.author_id
JOIN topics t ON t.id = b.topic_id
LEFT JOIN votes v ON v.bill_id = b.id
`;
app.get("/api/bills", (req, res) => {
const topic = typeof req.query.topic === "string" ? req.query.topic : "";
const status = typeof req.query.status === "string" ? req.query.status : "";
const sort = req.query.sort === "votes" ? "voteCount DESC, b.created_at DESC" : "b.created_at DESC";
const clauses: string[] = [];
const params: string[] = [];
if (topic) { clauses.push("t.slug = ?"); params.push(topic); }
if (status) { clauses.push("b.status = ?"); params.push(status); }
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const bills = db.prepare(`${billSelect} ${where} GROUP BY b.id ORDER BY ${sort}`).all(...params);
res.json({ bills });
});
app.get("/api/bills/:id", (req: AuthedRequest, res) => {
const bill = db.prepare(`${billSelect} WHERE b.id = ? GROUP BY b.id`).get(req.params.id) as Record<string, unknown> | undefined;
if (!bill) return res.status(404).json({ message: "Der Entwurf wurde nicht gefunden." });
const userVote = req.user
? (db.prepare("SELECT value FROM votes WHERE bill_id = ? AND user_id = ?").get(req.params.id, req.user.id) as { value: string } | undefined)?.value
: null;
return res.json({ bill: { ...bill, userVote } });
});
const billSchema = z.object({
title: z.string().trim().min(10).max(140),
summary: z.string().trim().min(30).max(300),
problem: z.string().trim().min(40).max(2000),
solution: z.string().trim().min(40).max(3000),
impact: z.string().trim().min(30).max(2000),
topicId: z.coerce.number().int().positive(),
deadline: z.string().datetime(),
});
app.post("/api/bills", requireUser, (req: AuthedRequest, res) => {
const parsed = billSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ message: parsed.error.issues[0]?.message });
if (new Date(parsed.data.deadline).getTime() < Date.now() + 24 * 60 * 60 * 1000) {
return res.status(400).json({ message: "Die Abstimmung muss mindestens 24 Stunden laufen." });
}
const data = parsed.data;
const result = db.prepare(`
INSERT INTO bills (title, summary, problem, solution, impact, topic_id, author_id, deadline)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(data.title, data.summary, data.problem, data.solution, data.impact, data.topicId, req.user!.id, data.deadline);
return res.status(201).json({ id: result.lastInsertRowid });
});
app.post("/api/bills/:id/vote", requireUser, (req: AuthedRequest, res) => {
const parsed = z.object({ value: z.enum(["yes", "no", "abstain"]) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ message: "Ungültige Abstimmung." });
const bill = db.prepare("SELECT id, status, deadline FROM bills WHERE id = ?").get(req.params.id) as { id: number; status: string; deadline: string } | undefined;
if (!bill) return res.status(404).json({ message: "Der Entwurf wurde nicht gefunden." });
if (bill.status === "closed" || new Date(bill.deadline) < new Date()) {
return res.status(409).json({ message: "Die Abstimmung ist bereits beendet." });
}
db.prepare(`
INSERT INTO votes (user_id, bill_id, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id) DO UPDATE SET value = excluded.value, created_at = CURRENT_TIMESTAMP
`).run(req.user!.id, bill.id, parsed.data.value);
return res.json({ value: parsed.data.value });
});
app.get("/api/stats", (_req, res) => {
const stats = db.prepare(`
SELECT
(SELECT COUNT(*) FROM users) AS users,
(SELECT COUNT(*) FROM bills WHERE status != 'closed') AS activeBills,
(SELECT COUNT(*) FROM votes) AS votes
`).get();
res.json({ stats });
});
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ message: "Ein unerwarteter Fehler ist aufgetreten." });
});

44
apps/api/src/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { NextFunction, Response } from "express";
import jwt from "jsonwebtoken";
import { db } from "./db.js";
import type { AuthedRequest, AuthUser } from "./types.js";
const JWT_SECRET = process.env.JWT_SECRET || "development-only-change-me";
const COOKIE_NAME = "mitwirkung_session";
export function signIn(res: Response, userId: number) {
const token = jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: "7d" });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
export function signOut(res: Response) {
res.clearCookie(COOKIE_NAME, { httpOnly: true, sameSite: "lax" });
}
export function attachUser(req: AuthedRequest, _res: Response, next: NextFunction) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return next();
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
const user = db
.prepare("SELECT id, name, email FROM users WHERE id = ?")
.get(Number(payload.sub)) as AuthUser | undefined;
if (user) req.user = user;
} catch {
// An invalid or expired cookie is treated as an anonymous session.
}
next();
}
export function requireUser(req: AuthedRequest, res: Response, next: NextFunction) {
if (!req.user) {
res.status(401).json({ message: "Bitte melde dich an, um fortzufahren." });
return;
}
next();
}

174
apps/api/src/db.ts Normal file
View File

@@ -0,0 +1,174 @@
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const defaultPath = resolve(here, "../../data/mitwirkung.db");
const databasePath = process.env.DATABASE_PATH || defaultPath;
if (databasePath !== ":memory:") mkdirSync(dirname(databasePath), { recursive: true });
export const db = new Database(databasePath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
export function initializeDatabase() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
color TEXT NOT NULL,
icon TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
summary TEXT NOT NULL,
problem TEXT NOT NULL,
solution TEXT NOT NULL,
impact TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'review', 'closed')),
topic_id INTEGER NOT NULL REFERENCES topics(id),
author_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deadline TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS votes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
value TEXT NOT NULL CHECK(value IN ('yes', 'no', 'abstain')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, bill_id)
);
`);
seedDatabase();
}
function seedDatabase() {
const insertTopic = db.prepare(
"INSERT OR IGNORE INTO topics (slug, name, color, icon) VALUES (?, ?, ?, ?)",
);
const topics = [
["klima", "Klima & Energie", "#157f6b", "leaf"],
["digitales", "Digitales", "#4263a9", "cpu"],
["bildung", "Bildung", "#a85d35", "book-open"],
["soziales", "Soziales", "#8b4f83", "heart-handshake"],
["mobilitaet", "Mobilität", "#b27a19", "tram-front"],
["demokratie", "Demokratie", "#476a51", "landmark"],
];
const insertTopics = db.transaction(() => topics.forEach((topic) => insertTopic.run(...topic)));
insertTopics();
const count = db.prepare("SELECT COUNT(*) AS count FROM bills").get() as { count: number };
if (count.count > 0) return;
const passwordHash = bcrypt.hashSync("demo1234", 10);
const insertUser = db.prepare(
"INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",
);
const users = [
["Leonie Hartmann", "leonie@mitwirkung.de"],
["Samir Yilmaz", "samir@mitwirkung.de"],
["Eva Neumann", "eva@mitwirkung.de"],
].map(([name, email]) => Number(insertUser.run(name, email, passwordHash).lastInsertRowid));
const topicIds = Object.fromEntries(
(db.prepare("SELECT id, slug FROM topics").all() as Array<{ id: number; slug: string }>).map((t) => [
t.slug,
t.id,
]),
);
const insertBill = db.prepare(`
INSERT INTO bills
(title, summary, problem, solution, impact, status, topic_id, author_id, created_at, deadline)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const bills = [
[
"Solardächer auf öffentlichen Gebäuden bis 2030",
"Bund, Länder und Kommunen sollen geeignete Dachflächen systematisch für Solarenergie nutzen.",
"Viele öffentliche Dachflächen bleiben ungenutzt, obwohl Kommunen gleichzeitig hohe Energiekosten tragen.",
"Ein bundesweites Kataster erfasst geeignete Flächen. Für Neubauten gilt eine Solarpflicht, Bestandsbauten werden über einen Investitionsfonds gefördert.",
"Die Maßnahme senkt langfristig Betriebskosten, stärkt lokale Energieversorgung und schafft planbare Aufträge für das Handwerk.",
"open",
topicIds.klima,
users[0],
"2026-05-28T09:15:00.000Z",
"2026-07-18T22:00:00.000Z",
],
[
"Recht auf ein analoges Verwaltungsverfahren",
"Zentrale Behördengänge müssen auch ohne Smartphone oder digitales Bürgerkonto möglich bleiben.",
"Die fortschreitende Digitalisierung kann Menschen ausschließen, die keinen verlässlichen digitalen Zugang besitzen.",
"Behörden müssen für wesentliche Leistungen einen gleichwertigen analogen Zugangsweg anbieten und verständlich ausweisen.",
"Mehr Teilhabe für ältere, einkommensarme und technisch weniger versierte Menschen bei überschaubarem Verwaltungsaufwand.",
"review",
topicIds.digitales,
users[1],
"2026-05-22T12:00:00.000Z",
"2026-06-30T22:00:00.000Z",
],
[
"Kostenloses Mittagessen an allen Schulen",
"Jedes Kind soll unabhängig vom Einkommen der Eltern eine ausgewogene Mahlzeit erhalten.",
"Ernährungsarmut beeinträchtigt Konzentration und Gesundheit. Unterschiedliche kommunale Modelle führen zu ungleichen Chancen.",
"Ein gemeinsamer Bund-Länder-Fonds finanziert ein kostenfreies, regional ausgerichtetes Mittagessen an allgemeinbildenden Schulen.",
"Bessere Lernbedingungen, Entlastung von Familien und verlässlichere Nachfrage für regionale Lebensmittelbetriebe.",
"open",
topicIds.bildung,
users[2],
"2026-06-02T07:30:00.000Z",
"2026-08-05T22:00:00.000Z",
],
[
"Bundesweiter Mobilitätspass für junge Menschen",
"Menschen bis 27 erhalten ein vergünstigtes Ticket für Nahverkehr und Leihradsysteme.",
"Hohe Mobilitätskosten beschränken Ausbildung, Freizeit und gesellschaftliche Teilhabe junger Menschen.",
"Ein digital und analog verfügbarer Pass bündelt ÖPNV und kommunale Leihräder für höchstens 19 Euro im Monat.",
"Mehr klimafreundliche Mobilität und bessere Erreichbarkeit von Ausbildungsorten, besonders außerhalb der Ballungsräume.",
"open",
topicIds.mobilitaet,
users[1],
"2026-06-07T14:20:00.000Z",
"2026-08-20T22:00:00.000Z",
],
[
"Bürgergutachten vor großen Infrastrukturprojekten",
"Zufällig ausgeloste Bürgerräte sollen Großprojekte frühzeitig prüfen und öffentlich Empfehlungen abgeben.",
"Planungsverfahren werden oft erst wahrgenommen, wenn zentrale Entscheidungen bereits gefallen sind.",
"Bei Projekten ab 250 Millionen Euro wird vor dem Raumordnungsverfahren ein repräsentativer Bürgerrat einberufen.",
"Frühere Konfliktklärung, besser dokumentierte Abwägungen und höhere Nachvollziehbarkeit politischer Entscheidungen.",
"open",
topicIds.demokratie,
users[0],
"2026-05-30T16:45:00.000Z",
"2026-07-25T22:00:00.000Z",
],
];
const insertBills = db.transaction(() => bills.forEach((bill) => insertBill.run(...bill)));
insertBills();
const insertVote = db.prepare("INSERT INTO votes (user_id, bill_id, value) VALUES (?, ?, ?)");
const seededVotes: Array<[number, number, string]> = [
[users[0]!, 2, "yes"], [users[0]!, 3, "yes"], [users[0]!, 4, "no"],
[users[1]!, 1, "yes"], [users[1]!, 3, "yes"], [users[1]!, 5, "abstain"],
[users[2]!, 1, "yes"], [users[2]!, 2, "no"], [users[2]!, 4, "yes"], [users[2]!, 5, "yes"],
];
const insertVotes = db.transaction(() => seededVotes.forEach((vote) => insertVote.run(...vote)));
insertVotes();
}

6
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { app } from "./app.js";
const port = Number(process.env.PORT || 4000);
app.listen(port, () => {
console.log(`Mitwirkung API läuft auf http://localhost:${port}`);
});

17
apps/api/src/types.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { Request } from "express";
export type Topic = {
id: number;
slug: string;
name: string;
color: string;
icon: string;
};
export type AuthUser = {
id: number;
name: string;
email: string;
};
export type AuthedRequest = Request & { user?: AuthUser };

14
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "mitwirkung",
"private": true,
"version": "1.0.0",
"workspaces": ["apps/*"],
"scripts": {
"dev": "concurrently -n API,WEB -c blue,green \"npm run dev -w @mitwirkung/api\" \"npm run dev -w @mitwirkung/web\"",
"build": "npm run build -w @mitwirkung/api && npm run build -w @mitwirkung/web",
"test": "npm run test -w @mitwirkung/api",
"start": "npm run start -w @mitwirkung/api"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}