kaboot/server/src/routes/quizzes.ts

378 lines
10 KiB
TypeScript

import { Router, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { randomBytes } from 'crypto';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
interface GameConfig {
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
randomNamesEnabled?: boolean;
maxPlayers?: number;
streakBonusEnabled: boolean;
streakThreshold: number;
streakMultiplier: number;
comebackBonusEnabled: boolean;
comebackBonusPoints: number;
penaltyForWrongAnswer: boolean;
penaltyPercent: number;
firstCorrectBonusEnabled: boolean;
firstCorrectBonusPoints: number;
}
interface QuizBody {
title: string;
source: 'manual' | 'ai_generated';
aiTopic?: string;
gameConfig?: GameConfig;
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.share_token as shareToken,
q.is_shared as isShared,
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.map((q: any) => ({ ...q, isShared: Boolean(q.isShared) })));
});
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, share_token as shareToken, is_shared as isShared,
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),
})),
};
});
let parsedConfig = null;
if (quiz.gameConfig && typeof quiz.gameConfig === 'string') {
try {
parsedConfig = JSON.parse(quiz.gameConfig);
} catch {
parsedConfig = null;
}
}
res.json({
...quiz,
isShared: Boolean(quiz.isShared),
gameConfig: parsedConfig,
questions: questionsWithOptions,
});
});
function validateQuizBody(body: QuizBody): string | null {
const { title, source, questions } = body;
if (!title?.trim()) {
return 'Title is required and cannot be empty';
}
if (!source || !['manual', 'ai_generated'].includes(source)) {
return 'Source must be "manual" or "ai_generated"';
}
if (!questions || !Array.isArray(questions) || questions.length === 0) {
return 'At least one question is required';
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.text?.trim()) {
return `Question ${i + 1} text is required`;
}
if (!q.options || !Array.isArray(q.options) || q.options.length < 2) {
return `Question ${i + 1} must have at least 2 options`;
}
const hasCorrect = q.options.some(o => o.isCorrect);
if (!hasCorrect) {
return `Question ${i + 1} must have at least one correct answer`;
}
}
return null;
}
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, gameConfig, questions } = body;
const validationError = validateQuizBody(body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const quizId = uuidv4();
const upsertUser = db.prepare(`
INSERT INTO users (id, last_login)
VALUES (?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
last_login = CURRENT_TIMESTAMP
`);
const insertQuiz = db.prepare(`
INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config)
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
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : 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, gameConfig } = body;
const quizId = req.params.id;
if (!title?.trim()) {
res.status(400).json({ error: 'Title is required and cannot be empty' });
return;
}
if (!questions || !Array.isArray(questions) || questions.length === 0) {
res.status(400).json({ error: 'At least one question is required' });
return;
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.text?.trim()) {
res.status(400).json({ error: `Question ${i + 1} text is required` });
return;
}
if (!q.options || !Array.isArray(q.options) || q.options.length < 2) {
res.status(400).json({ error: `Question ${i + 1} must have at least 2 options` });
return;
}
const hasCorrect = q.options.some(o => o.isCorrect);
if (!hasCorrect) {
res.status(400).json({ error: `Question ${i + 1} must have at least one correct answer` });
return;
}
}
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 = ?, game_config = ?, 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, gameConfig ? JSON.stringify(gameConfig) : null, 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.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
const quizId = req.params.id;
const { gameConfig } = req.body as { gameConfig: GameConfig };
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;
}
db.prepare(`
UPDATE quizzes SET game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(gameConfig ? JSON.stringify(gameConfig) : null, quizId);
res.json({ success: true });
});
router.post('/:id/share', (req: AuthenticatedRequest, res: Response) => {
const quizId = req.params.id;
const existing = db.prepare(`
SELECT id, share_token as shareToken FROM quizzes WHERE id = ? AND user_id = ?
`).get(quizId, req.user!.sub) as { id: string; shareToken: string | null } | undefined;
if (!existing) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
if (existing.shareToken) {
res.json({ shareToken: existing.shareToken });
return;
}
const shareToken = randomBytes(16).toString('base64url');
const sharedBy = req.user!.name || req.user!.preferred_username || null;
db.prepare(`
UPDATE quizzes
SET share_token = ?, shared_by = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(shareToken, sharedBy, quizId);
res.json({ shareToken });
});
router.delete('/:id/share', (req: AuthenticatedRequest, res: Response) => {
const quizId = req.params.id;
const result = db.prepare(`
UPDATE quizzes SET share_token = NULL, is_shared = 0, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
`).run(quizId, req.user!.sub);
if (result.changes === 0) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
res.json({ success: true });
});
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;