Add sharing

This commit is contained in:
Joey Yakimowich-Payne 2026-01-16 08:49:21 -07:00
commit 8a11275849
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
16 changed files with 1996 additions and 10 deletions

View file

@ -108,6 +108,25 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
console.log("Migration: Added gemini_model to users");
}
const quizTableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
const hasShareToken = quizTableInfo.some(col => col.name === "share_token");
if (!hasShareToken) {
db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT UNIQUE");
console.log("Migration: Added share_token to quizzes");
}
const hasIsShared = quizTableInfo.some(col => col.name === "is_shared");
if (!hasIsShared) {
db.exec("ALTER TABLE quizzes ADD COLUMN is_shared INTEGER DEFAULT 0");
console.log("Migration: Added is_shared to quizzes");
}
const shareTokenIndex = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_quizzes_share_token'").get();
if (!shareTokenIndex) {
db.exec("CREATE INDEX idx_quizzes_share_token ON quizzes(share_token)");
console.log("Migration: Created index on quizzes.share_token");
}
};
runMigrations();

View file

@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS quizzes (
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
game_config TEXT,
share_token TEXT UNIQUE,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
@ -58,6 +60,7 @@ CREATE TABLE IF NOT EXISTS game_sessions (
);
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
CREATE INDEX IF NOT EXISTS idx_quizzes_share_token ON quizzes(share_token);
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);
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);

View file

@ -8,6 +8,7 @@ import usersRouter from './routes/users.js';
import uploadRouter from './routes/upload.js';
import gamesRouter from './routes/games.js';
import generateRouter from './routes/generate.js';
import sharedRouter from './routes/shared.js';
const app = express();
const PORT = process.env.PORT || 3001;
@ -93,6 +94,7 @@ app.use('/api/users', usersRouter);
app.use('/api/upload', uploadRouter);
app.use('/api/games', gamesRouter);
app.use('/api/generate', generateRouter);
app.use('/api/shared', sharedRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err);

View file

@ -1,5 +1,6 @@
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';
@ -47,6 +48,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
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
@ -55,7 +58,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
ORDER BY q.updated_at DESC
`).all(req.user!.sub);
res.json(quizzes);
res.json(quizzes.map((q: any) => ({ ...q, isShared: Boolean(q.isShared) })));
});
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
@ -315,6 +318,48 @@ router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
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');
db.prepare(`
UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(shareToken, 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 = ?

185
server/src/routes/shared.ts Normal file
View file

@ -0,0 +1,185 @@
import { Router, Request, 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();
interface QuizRow {
id: string;
title: string;
source: string;
aiTopic: string | null;
gameConfig: string | null;
createdAt: string;
updatedAt: string;
}
interface QuestionRow {
id: string;
text: string;
timeLimit: number;
orderIndex: number;
}
interface OptionRow {
id: string;
text: string;
isCorrect: number;
shape: string;
color: string;
reason: string | null;
orderIndex: number;
}
router.get('/:token', (req: Request, res: Response) => {
const { token } = req.params;
const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
FROM quizzes
WHERE share_token = ? AND is_shared = 1
`).get(token) as QuizRow | undefined;
if (!quiz) {
res.status(404).json({ error: 'Shared 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 QuestionRow[];
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 OptionRow[];
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({
title: quiz.title,
source: quiz.source,
aiTopic: quiz.aiTopic,
gameConfig: parsedConfig,
questions: questionsWithOptions,
questionCount: questions.length,
});
});
router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Response) => {
const { token } = req.params;
const sourceQuiz = db.prepare(`
SELECT id, title, game_config as gameConfig
FROM quizzes
WHERE share_token = ? AND is_shared = 1
`).get(token) as { id: string; title: string; gameConfig: string | null } | undefined;
if (!sourceQuiz) {
res.status(404).json({ error: 'Shared quiz not found' });
return;
}
const existingWithSameTitle = db.prepare(`
SELECT id FROM quizzes WHERE user_id = ? AND title = ?
`).get(req.user!.sub, sourceQuiz.title);
const newTitle = existingWithSameTitle ? `${sourceQuiz.title} (Copy)` : sourceQuiz.title;
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(sourceQuiz.id) as QuestionRow[];
const newQuizId = 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, game_config)
VALUES (?, ?, ?, 'manual', ?)
`);
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(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);
questions.forEach((q, qIdx) => {
const newQuestionId = uuidv4();
insertQuestion.run(newQuestionId, newQuizId, q.text, q.timeLimit, qIdx);
const options = db.prepare(`
SELECT text, is_correct, shape, color, reason, order_index
FROM answer_options
WHERE question_id = ?
ORDER BY order_index
`).all(q.id) as { text: string; is_correct: number; shape: string; color: string; reason: string | null; order_index: number }[];
options.forEach((o, oIdx) => {
insertOption.run(
uuidv4(),
newQuestionId,
o.text,
o.is_correct,
o.shape,
o.color,
o.reason,
oIdx
);
});
});
});
transaction();
res.status(201).json({ id: newQuizId, title: newTitle });
});
export default router;