Phase 2 + 3 complete
This commit is contained in:
parent
9a3fc97a34
commit
6d24f3c112
25 changed files with 3275 additions and 98 deletions
19
server/src/db/connection.ts
Normal file
19
server/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Database, { Database as DatabaseType } from 'better-sqlite3';
|
||||
import { readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = process.env.DATABASE_PATH || join(__dirname, '../../../data/kaboot.db');
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
export const db: DatabaseType = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
console.log(`Database initialized at ${DB_PATH}`);
|
||||
44
server/src/db/schema.sql
Normal file
44
server/src/db/schema.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quizzes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
||||
ai_topic TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
quiz_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
time_limit INTEGER DEFAULT 20,
|
||||
order_index INTEGER NOT NULL,
|
||||
FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS answer_options (
|
||||
id TEXT PRIMARY KEY,
|
||||
question_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
is_correct INTEGER NOT NULL,
|
||||
shape TEXT NOT NULL CHECK(shape IN ('triangle', 'diamond', 'circle', 'square')),
|
||||
color TEXT NOT NULL CHECK(color IN ('red', 'blue', 'yellow', 'green')),
|
||||
reason TEXT,
|
||||
order_index INTEGER NOT NULL,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
|
||||
|
|
@ -1,16 +1,38 @@
|
|||
import express from 'express';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import { db } from './db/connection.js';
|
||||
import quizzesRouter from './routes/quizzes.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
|
||||
app.use(express.json());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use('/api/quizzes', quizzesRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Kaboot backend running on port ${PORT}`);
|
||||
console.log(`Database: ${process.env.DATABASE_PATH || 'default location'}`);
|
||||
console.log(`CORS origin: ${process.env.CORS_ORIGIN || 'http://localhost:5173'}`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Shutting down...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
|
|||
82
server/src/middleware/auth.ts
Normal file
82
server/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/';
|
||||
const OIDC_JWKS_URI = process.env.OIDC_JWKS_URI || 'http://localhost:9000/application/o/kaboot/jwks/';
|
||||
const OIDC_INTERNAL_JWKS_URI = process.env.OIDC_INTERNAL_JWKS_URI || OIDC_JWKS_URI;
|
||||
|
||||
const client = jwksClient({
|
||||
jwksUri: OIDC_INTERNAL_JWKS_URI,
|
||||
cache: true,
|
||||
cacheMaxAge: 600000,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 10,
|
||||
});
|
||||
|
||||
function getSigningKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void {
|
||||
if (!header.kid) {
|
||||
callback(new Error('No kid in token header'));
|
||||
return;
|
||||
}
|
||||
client.getSigningKey(header.kid, (err, key) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
const signingKey = key?.getPublicKey();
|
||||
callback(null, signingKey);
|
||||
});
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
sub: string;
|
||||
preferred_username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export function requireAuth(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
jwt.verify(
|
||||
token,
|
||||
getSigningKey,
|
||||
{
|
||||
issuer: OIDC_ISSUER,
|
||||
algorithms: ['RS256'],
|
||||
},
|
||||
(err, decoded) => {
|
||||
if (err) {
|
||||
console.error('Token verification failed:', err.message);
|
||||
res.status(401).json({ error: 'Invalid token', details: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = decoded as jwt.JwtPayload;
|
||||
req.user = {
|
||||
sub: payload.sub!,
|
||||
preferred_username: payload.preferred_username || payload.sub!,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
);
|
||||
}
|
||||
225
server/src/routes/quizzes.ts
Normal file
225
server/src/routes/quizzes.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from '../db/connection.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
interface QuizBody {
|
||||
title: string;
|
||||
source: 'manual' | 'ai_generated';
|
||||
aiTopic?: string;
|
||||
questions: {
|
||||
text: string;
|
||||
timeLimit?: number;
|
||||
options: {
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
shape: string;
|
||||
color: string;
|
||||
reason?: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
||||
const quizzes = db.prepare(`
|
||||
SELECT
|
||||
q.id,
|
||||
q.title,
|
||||
q.source,
|
||||
q.ai_topic as aiTopic,
|
||||
q.created_at as createdAt,
|
||||
q.updated_at as updatedAt,
|
||||
(SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount
|
||||
FROM quizzes q
|
||||
WHERE q.user_id = ?
|
||||
ORDER BY q.updated_at DESC
|
||||
`).all(req.user!.sub);
|
||||
|
||||
res.json(quizzes);
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
const quiz = db.prepare(`
|
||||
SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt
|
||||
FROM quizzes
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!quiz) {
|
||||
res.status(404).json({ error: 'Quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = db.prepare(`
|
||||
SELECT id, text, time_limit as timeLimit, order_index as orderIndex
|
||||
FROM questions
|
||||
WHERE quiz_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(quiz.id) as Record<string, unknown>[];
|
||||
|
||||
const questionsWithOptions = questions.map((q) => {
|
||||
const options = db.prepare(`
|
||||
SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex
|
||||
FROM answer_options
|
||||
WHERE question_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(q.id) as Record<string, unknown>[];
|
||||
|
||||
return {
|
||||
...q,
|
||||
options: options.map((o) => ({
|
||||
...o,
|
||||
isCorrect: Boolean(o.isCorrect),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
...quiz,
|
||||
questions: questionsWithOptions,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||
const body = req.body as QuizBody;
|
||||
const { title, source, aiTopic, questions } = body;
|
||||
|
||||
if (!title || !source || !questions?.length) {
|
||||
res.status(400).json({ error: 'Missing required fields: title, source, questions' });
|
||||
return;
|
||||
}
|
||||
|
||||
const quizId = uuidv4();
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, email, display_name, last_login)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_login = CURRENT_TIMESTAMP,
|
||||
email = COALESCE(excluded.email, users.email),
|
||||
display_name = COALESCE(excluded.display_name, users.display_name)
|
||||
`);
|
||||
|
||||
const insertQuiz = db.prepare(`
|
||||
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertQuestion = db.prepare(`
|
||||
INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertOption = db.prepare(`
|
||||
INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
upsertUser.run(
|
||||
req.user!.sub,
|
||||
req.user!.preferred_username,
|
||||
req.user!.email || null,
|
||||
req.user!.name || null
|
||||
);
|
||||
|
||||
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null);
|
||||
|
||||
questions.forEach((q, qIdx) => {
|
||||
const questionId = uuidv4();
|
||||
insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx);
|
||||
|
||||
q.options.forEach((o, oIdx) => {
|
||||
insertOption.run(
|
||||
uuidv4(),
|
||||
questionId,
|
||||
o.text,
|
||||
o.isCorrect ? 1 : 0,
|
||||
o.shape,
|
||||
o.color,
|
||||
o.reason || null,
|
||||
oIdx
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
res.status(201).json({ id: quizId });
|
||||
});
|
||||
|
||||
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
const body = req.body as QuizBody;
|
||||
const { title, questions } = body;
|
||||
const quizId = req.params.id;
|
||||
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM quizzes WHERE id = ? AND user_id = ?
|
||||
`).get(quizId, req.user!.sub);
|
||||
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateQuiz = db.prepare(`
|
||||
UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`);
|
||||
|
||||
const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`);
|
||||
|
||||
const insertQuestion = db.prepare(`
|
||||
INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertOption = db.prepare(`
|
||||
INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
updateQuiz.run(title, quizId);
|
||||
deleteQuestions.run(quizId);
|
||||
|
||||
questions.forEach((q, qIdx) => {
|
||||
const questionId = uuidv4();
|
||||
insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx);
|
||||
|
||||
q.options.forEach((o, oIdx) => {
|
||||
insertOption.run(
|
||||
uuidv4(),
|
||||
questionId,
|
||||
o.text,
|
||||
o.isCorrect ? 1 : 0,
|
||||
o.shape,
|
||||
o.color,
|
||||
o.reason || null,
|
||||
oIdx
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
res.json({ id: quizId });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
const result = db.prepare(`
|
||||
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
||||
`).run(req.params.id, req.user!.sub);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
server/src/routes/users.ts
Normal file
32
server/src/routes/users.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { db } from '../db/connection.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(req.user!.sub) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!user) {
|
||||
res.json({
|
||||
id: req.user!.sub,
|
||||
username: req.user!.preferred_username,
|
||||
email: req.user!.email,
|
||||
displayName: req.user!.name,
|
||||
createdAt: null,
|
||||
lastLogin: null,
|
||||
isNew: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ ...user, isNew: false });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Add a link
Reference in a new issue