Initial commit: Mathe-App Phase 1-3

- React+Vite Frontend mit Routing, eigenem fetch-Client (kein axios)
- Express Backend: Auth (JWT), Topics, Tasks, Leaderboard
- PostgreSQL Schema + Seed: 7 Kategorien, 21 Topics, ~25 Aufgaben
- Gamification: XP, Level (100×n^1.5), tägliche Streaks
- docker-compose auf Port 3100 für DS1621
- Alltagsaufgaben: Finanzen, Geometrie, Physik, Informatik, Verkehr, Shopping
This commit is contained in:
rene 2026-04-06 17:24:35 +02:00
commit c8b354ed45
49 changed files with 6127 additions and 0 deletions

1157
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
backend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0"
}
}

12
backend/src/db/index.js Normal file
View file

@ -0,0 +1,12 @@
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'matheapp',
user: process.env.DB_USER || 'mathe',
password: process.env.DB_PASSWORD,
});
export default pool;

24
backend/src/index.js Normal file
View file

@ -0,0 +1,24 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import healthRouter from './routes/health.js';
import authRouter from './routes/auth.js';
import usersRouter from './routes/users.js';
import topicsRouter from './routes/topics.js';
import tasksRouter from './routes/tasks.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173' }));
app.use(express.json());
app.use('/api/health', healthRouter);
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
app.use('/api/topics', topicsRouter);
app.use('/api/tasks', tasksRouter);
app.listen(PORT, () => {
console.log(`Backend running on port ${PORT}`);
});

View file

@ -0,0 +1,14 @@
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Nicht angemeldet' });
}
try {
req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ message: 'Token ungültig oder abgelaufen' });
}
}

View file

@ -0,0 +1,90 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import pool from '../db/index.js';
const router = Router();
function signToken(user) {
return jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
}
// POST /api/auth/register
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ message: 'Alle Felder sind erforderlich' });
}
if (password.length < 8) {
return res.status(400).json({ message: 'Passwort muss mindestens 8 Zeichen haben' });
}
try {
const hash = await bcrypt.hash(password, 12);
const result = await pool.query(
`INSERT INTO users (username, email, password)
VALUES ($1, $2, $3)
RETURNING id, username, email, xp, level, streak`,
[username.trim(), email.toLowerCase().trim(), hash]
);
const user = result.rows[0];
res.status(201).json({ token: signToken(user), user });
} catch (err) {
if (err.code === '23505') {
const field = err.constraint?.includes('email') ? 'E-Mail' : 'Benutzername';
return res.status(409).json({ message: `${field} ist bereits vergeben` });
}
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'E-Mail und Passwort erforderlich' });
}
try {
const result = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email.toLowerCase().trim()]
);
const user = result.rows[0];
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: 'E-Mail oder Passwort falsch' });
}
// Streak-Logik: +1 wenn letzter Login gestern, reset wenn länger her
const today = new Date().toISOString().slice(0, 10);
const last = user.streak_last ? user.streak_last.toISOString().slice(0, 10) : null;
let streak = user.streak;
if (last !== today) {
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
streak = last === yesterday ? streak + 1 : 1;
await pool.query(
'UPDATE users SET streak = $1, streak_last = $2 WHERE id = $3',
[streak, today, user.id]
);
}
const { password: _, ...safeUser } = user;
safeUser.streak = streak;
res.json({ token: signToken(user), user: safeUser });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
export default router;

View file

@ -0,0 +1,15 @@
import { Router } from 'express';
import pool from '../db/index.js';
const router = Router();
router.get('/', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', db: 'connected' });
} catch (err) {
res.status(503).json({ status: 'error', db: 'disconnected', message: err.message });
}
});
export default router;

View file

@ -0,0 +1,77 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import pool from '../db/index.js';
import { addXp } from '../utils/xp.js';
const router = Router();
// POST /api/tasks/:id/submit — Antwort einreichen
router.post('/:id/submit', requireAuth, async (req, res) => {
const taskId = parseInt(req.params.id, 10);
const { answer } = req.body;
if (answer === undefined || answer === '') {
return res.status(400).json({ message: 'Antwort fehlt' });
}
try {
const taskResult = await pool.query(
'SELECT * FROM tasks WHERE id = $1', [taskId]
);
const task = taskResult.rows[0];
if (!task) return res.status(404).json({ message: 'Aufgabe nicht gefunden' });
// Bereits gelöst?
const existing = await pool.query(
'SELECT * FROM user_progress WHERE user_id = $1 AND task_id = $2',
[req.user.id, taskId]
);
let correct = false;
if (task.answer_type === 'number') {
const given = parseFloat(String(answer).replace(',', '.'));
const expected = parseFloat(task.correct);
const tolerance = parseFloat(task.tolerance) || 0;
correct = !isNaN(given) && Math.abs(given - expected) <= tolerance;
} else {
correct = String(answer).trim().toLowerCase() === String(task.correct).trim().toLowerCase();
}
let xpGained = 0;
if (existing.rows[0]) {
// Bereits versucht — nur Ergebnis zurückgeben, kein XP
return res.json({
correct,
explanation: task.explanation,
xpGained: 0,
alreadySolved: true,
});
}
await pool.query(
'INSERT INTO user_progress (user_id, task_id, correct) VALUES ($1, $2, $3)',
[req.user.id, taskId, correct]
);
if (correct) {
const updated = await addXp(pool, req.user.id, task.xp_reward);
xpGained = task.xp_reward;
return res.json({
correct: true,
explanation: task.explanation,
xpGained,
newXp: updated.xp,
newLevel: updated.level,
});
}
res.json({ correct: false, explanation: task.explanation, xpGained: 0 });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
export default router;

View file

@ -0,0 +1,60 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import pool from '../db/index.js';
const router = Router();
// GET /api/topics — alle Kategorien mit Topics
router.get('/', requireAuth, async (req, res) => {
try {
const cats = await pool.query(
'SELECT id, slug, title, icon FROM categories ORDER BY sort_order'
);
const topics = await pool.query(
`SELECT t.id, t.category_id, t.slug, t.title, t.description, t.difficulty, t.is_premium,
COUNT(tk.id) AS task_count
FROM topics t
LEFT JOIN tasks tk ON tk.topic_id = t.id
GROUP BY t.id
ORDER BY t.sort_order`
);
const result = cats.rows.map(cat => ({
...cat,
topics: topics.rows.filter(t => t.category_id === cat.id),
}));
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
// GET /api/topics/:slug/tasks — Aufgaben eines Topics (mit Fortschritt)
router.get('/:slug/tasks', requireAuth, async (req, res) => {
try {
const topic = await pool.query(
'SELECT * FROM topics WHERE slug = $1', [req.params.slug]
);
if (!topic.rows[0]) return res.status(404).json({ message: 'Topic nicht gefunden' });
const tasks = await pool.query(
`SELECT tk.id, tk.title, tk.question, tk.answer_type, tk.choices,
tk.unit, tk.xp_reward, tk.difficulty,
up.correct AS solved, up.solved_at
FROM tasks tk
LEFT JOIN user_progress up ON up.task_id = tk.id AND up.user_id = $1
WHERE tk.topic_id = $2
ORDER BY tk.difficulty, tk.id`,
[req.user.id, topic.rows[0].id]
);
res.json({ topic: topic.rows[0], tasks: tasks.rows });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
export default router;

View file

@ -0,0 +1,38 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import pool from '../db/index.js';
const router = Router();
// GET /api/users/me
router.get('/me', requireAuth, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, username, email, xp, level, streak, is_premium, created_at FROM users WHERE id = $1',
[req.user.id]
);
if (!result.rows[0]) return res.status(404).json({ message: 'Nutzer nicht gefunden' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
// GET /api/users/leaderboard
router.get('/leaderboard', requireAuth, async (req, res) => {
try {
const result = await pool.query(
`SELECT username, xp, level, streak
FROM users
ORDER BY xp DESC
LIMIT 20`
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Serverfehler' });
}
});
export default router;

25
backend/src/utils/xp.js Normal file
View file

@ -0,0 +1,25 @@
// XP für Level n: 100 * n^1.5 (Level 1→100 XP, Level 10→3162 XP, Level 20→8944 XP)
export function xpForLevel(level) {
return Math.floor(100 * Math.pow(level, 1.5));
}
export function levelFromXp(totalXp) {
let level = 1;
let accumulated = 0;
while (accumulated + xpForLevel(level) <= totalXp) {
accumulated += xpForLevel(level);
level++;
}
return level;
}
export async function addXp(pool, userId, amount) {
const result = await pool.query(
'UPDATE users SET xp = xp + $1 WHERE id = $2 RETURNING xp',
[amount, userId]
);
const newXp = result.rows[0].xp;
const newLevel = levelFromXp(newXp);
await pool.query('UPDATE users SET level = $1 WHERE id = $2', [newLevel, userId]);
return { xp: newXp, level: newLevel };
}