diff --git a/.gitignore b/.gitignore index 2696d9a..7782aab 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..ce4fe7a --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..5c35db9 --- /dev/null +++ b/apps/api/src/app.ts @@ -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 | 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." }); +}); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..5261e30 --- /dev/null +++ b/apps/api/src/auth.ts @@ -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(); +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..aaa395d --- /dev/null +++ b/apps/api/src/db.ts @@ -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(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..6dbdd6b --- /dev/null +++ b/apps/api/src/index.ts @@ -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}`); +}); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 0000000..af82c34 --- /dev/null +++ b/apps/api/src/types.ts @@ -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 }; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..da725a9 --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9196015 --- /dev/null +++ b/package.json @@ -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" + } +}