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:
commit
c8b354ed45
49 changed files with 6127 additions and 0 deletions
1157
backend/package-lock.json
generated
Normal file
1157
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
backend/package.json
Normal file
22
backend/package.json
Normal 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
12
backend/src/db/index.js
Normal 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
24
backend/src/index.js
Normal 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}`);
|
||||
});
|
||||
14
backend/src/middleware/auth.js
Normal file
14
backend/src/middleware/auth.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
90
backend/src/routes/auth.js
Normal file
90
backend/src/routes/auth.js
Normal 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;
|
||||
15
backend/src/routes/health.js
Normal file
15
backend/src/routes/health.js
Normal 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;
|
||||
77
backend/src/routes/tasks.js
Normal file
77
backend/src/routes/tasks.js
Normal 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;
|
||||
60
backend/src/routes/topics.js
Normal file
60
backend/src/routes/topics.js
Normal 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;
|
||||
38
backend/src/routes/users.js
Normal file
38
backend/src/routes/users.js
Normal 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
25
backend/src/utils/xp.js
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue