378 lines
10 KiB
TypeScript
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;
|