Phase 2 + 3 complete

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 15:20:46 -07:00
commit 6d24f3c112
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
25 changed files with 3275 additions and 98 deletions

View 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
View 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);

View file

@ -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);
});

View 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();
}
);
}

View 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;

View 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;