Initialize API and Web application structure and configuration
This commit is contained in:
34
.gitignore
vendored
34
.gitignore
vendored
@@ -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
|
node_modules
|
||||||
out
|
dist
|
||||||
output
|
.env
|
||||||
src-tauri/gen
|
*.db
|
||||||
src-tauri/target
|
*.db-shm
|
||||||
target
|
*.db-wal
|
||||||
temp
|
.DS_Store
|
||||||
tmp
|
coverage
|
||||||
tmp*
|
|
||||||
venv
|
|
||||||
|
|||||||
34
apps/api/package.json
Normal file
34
apps/api/package.json
Normal 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
156
apps/api/src/app.ts
Normal 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
44
apps/api/src/auth.ts
Normal 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
174
apps/api/src/db.ts
Normal 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
6
apps/api/src/index.ts
Normal 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
17
apps/api/src/types.ts
Normal 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
14
apps/api/tsconfig.json
Normal 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
15
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user