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
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue