Public Access
feat: add collections management and study coach features
- Implemented collection repository with CRUD operations for user collections. - Created routes for managing collections, including listing, creating, updating, and deleting collections. - Added sharing functionality for exams, allowing users to share and clone exams. - Developed a study coach service that analyzes exam results and provides study tips based on weak areas. - Introduced frontend components for managing collections, sharing exams, and displaying study coach insights. - Enhanced user experience with loading states and error handling in shared components.
This commit is contained in:
1 parent
7755e77039
commit
1ec1633dd9
26 files changed
+2557
-127
No files matched your search
@@ -53,6 +53,19 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
`);
|
||||
|
||||
// ── Add share_token and is_public columns if missing ──
|
||||
const examCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_token TEXT`);
|
||||
}
|
||||
if (!examCols.some((c) => c.name === 'is_public')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_public INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_exams_share ON saved_exams(share_token);
|
||||
`);
|
||||
|
||||
// ── Add role column if missing (existing databases) ──
|
||||
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
|
||||
name: string;
|
||||
@@ -61,15 +74,53 @@ export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
||||
}
|
||||
|
||||
// ── Add storage_quota_bytes if missing ──
|
||||
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||
}
|
||||
// ── Add storage_quota_bytes if missing ──
|
||||
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||
}
|
||||
|
||||
// ── Add plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
// ── Add plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
|
||||
// ── Add is_favorite to saved_exams if missing ──
|
||||
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
// ── Add tags to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'tags')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
// ── Add collection_id to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
|
||||
}
|
||||
|
||||
// ── Add share_mode to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
|
||||
}
|
||||
|
||||
// ── Add cloned_from_share_token to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
|
||||
}
|
||||
|
||||
// ── Collections table ──
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
|
||||
`);
|
||||
|
||||
// ── Promote first user to admin if no admins exist ──
|
||||
const adminCount = db
|
||||
|
||||
@@ -10,6 +10,8 @@ import resultRoutes from './routes/results.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import setupRoutes from './routes/setup.js'
|
||||
import gradeRoutes from './routes/grade.js'
|
||||
import shareRoutes from './routes/share.js'
|
||||
import collectionRoutes from './routes/collections.js'
|
||||
import { errorHandler } from './middleware/error.js'
|
||||
import { config } from './config.js'
|
||||
import path from 'path'
|
||||
@@ -63,6 +65,8 @@ app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
app.use('/api/grade', gradeRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/api/share', shareRoutes)
|
||||
app.use('/api/collections', collectionRoutes)
|
||||
|
||||
// Health check with subsystem status
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbCollection {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface CollectionWithCount extends DbCollection {
|
||||
exam_count: number
|
||||
}
|
||||
|
||||
export function createCollection(userId: number, name: string): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collections (user_id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(userId, name)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getCollectionById(id: number, userId: number): DbCollection | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM collections WHERE id = ? AND user_id = ?').get(id, userId) as DbCollection | undefined
|
||||
}
|
||||
|
||||
export function listCollectionsByUser(userId: number): CollectionWithCount[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT c.*, COUNT(se.id) AS exam_count
|
||||
FROM collections c
|
||||
LEFT JOIN saved_exams se ON se.collection_id = c.id
|
||||
WHERE c.user_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
`).all(userId) as CollectionWithCount[]
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, userId: number, name: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE collections
|
||||
SET name = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(name, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM collections WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export interface DbExam {
|
||||
exam_json: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
share_token: string | null
|
||||
is_public: number
|
||||
is_favorite: number
|
||||
tags: string | null
|
||||
share_mode: string | null
|
||||
cloned_from_share_token: string | null
|
||||
}
|
||||
|
||||
export interface ExamListItem {
|
||||
@@ -17,14 +23,18 @@ export interface ExamListItem {
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string): { id: number } {
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string = '[]'): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson)
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson, tagsJson)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
@@ -36,16 +46,24 @@ export function getExamById(id: number, userId: number): DbExam | undefined {
|
||||
export function listExamsByUser(userId: number): ExamListItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
SELECT id, title, subtitle, created_at, updated_at, share_token, is_favorite, tags, collection_id,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY is_favorite DESC, updated_at DESC
|
||||
`).all(userId) as ExamListItem[]
|
||||
}
|
||||
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string | null = null): boolean {
|
||||
const db = getDb()
|
||||
if (tagsJson !== null) {
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, tags = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(title, subtitle, examJson, tagsJson, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||
@@ -65,3 +83,106 @@ export function countExams(): number {
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function setShareToken(id: number, userId: number, token: string, mode: string = 'clone'): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = ?, is_public = 1, share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(token, mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function clearShareToken(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = NULL, is_public = 0, share_mode = 'clone', updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamByShareToken(token: string): DbExam | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM saved_exams WHERE share_token = ? AND is_public = 1').get(token) as DbExam | undefined
|
||||
}
|
||||
|
||||
export function setShareMode(id: number, userId: number, mode: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function cloneExam(userId: number, sourceExam: DbExam, shareToken: string): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, cloned_from_share_token)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), shareToken)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export interface ClonedExamItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
cloned_from_share_token: string
|
||||
}
|
||||
|
||||
export function listClonedExams(userId: number): ClonedExamItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at, cloned_from_share_token,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ? AND cloned_from_share_token IS NOT NULL
|
||||
ORDER BY updated_at DESC
|
||||
`).all(userId) as ClonedExamItem[]
|
||||
}
|
||||
|
||||
export function setFavorite(id: number, userId: number, isFavorite: boolean): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET is_favorite = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(isFavorite ? 1 : 0, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamRawJson(id: number, userId: number): { title: string; exam_json: string } | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT title, exam_json FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as { title: string; exam_json: string } | undefined
|
||||
}
|
||||
|
||||
export function duplicateOwnExam(userId: number, sourceExam: DbExam): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), sourceExam.tags ?? '[]')
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function setExamCollection(id: number, userId: number, collectionId: number | null): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET collection_id = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(collectionId, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -110,3 +110,62 @@ export function countResultsByUser(userId: number): number {
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function getResultsWithExamJson(userId: number, limit = 100): Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT title, score_points, total_points, correct_count, graded_count, completed_at, exam_json, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ScoreTrend {
|
||||
completed_at: number
|
||||
score_pct: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function getScoreTrends(userId: number, limit = 30): Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT completed_at, score_points, total_points, title, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ? AND total_points > 0
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express'
|
||||
import { validateBody, validateParams } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as collectionRepo from '../repositories/collectionRepo.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import {
|
||||
createCollectionSchema,
|
||||
updateCollectionSchema,
|
||||
collectionIdParamSchema,
|
||||
moveExamSchema,
|
||||
} from '../validators/collectionSchemas.js'
|
||||
import { examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
// GET /api/collections
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const collections = collectionRepo.listCollectionsByUser(req.user!.userId)
|
||||
res.json(collections)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(createCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { id } = collectionRepo.createCollection(req.user!.userId, req.body.name)
|
||||
res.status(201).json({ id, name: req.body.name })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/collections/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
validateBody(updateCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = collectionRepo.updateCollection(id, req.user!.userId, req.body.name)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// DELETE /api/collections/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = collectionRepo.deleteCollection(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections/move/:examId
|
||||
router.post(
|
||||
'/move/:examId',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(moveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const examId = Number(req.params.examId)
|
||||
const collectionId = req.body.collectionId ?? null
|
||||
|
||||
// Verify exam belongs to user
|
||||
const exam = examRepo.getExamById(examId, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
|
||||
// If collectionId provided, verify it belongs to user
|
||||
if (collectionId !== null) {
|
||||
const collection = collectionRepo.getCollectionById(collectionId, req.user!.userId)
|
||||
if (!collection) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
}
|
||||
|
||||
examRepo.setExamCollection(examId, req.user!.userId, collectionId)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+132
-4
@@ -4,7 +4,7 @@ import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema, favoriteSchema, shareModeSchema, batchDeleteSchema } from '../validators/examSchemas.js'
|
||||
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
@@ -33,7 +33,14 @@ router.get(
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const parsedExam = JSON.parse(exam.exam_json)
|
||||
res.json({ ...parsedExam, id: exam.id })
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = JSON.parse(exam.tags ?? '[]')
|
||||
if (!Array.isArray(tags)) tags = []
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
res.json({ ...parsedExam, id: exam.id, tags })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -43,11 +50,13 @@ router.post(
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? req.body.tags : []
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
JSON.stringify(exam),
|
||||
JSON.stringify(tags)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
@@ -61,12 +70,14 @@ router.put(
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? JSON.stringify(req.body.tags) : null
|
||||
const updated = examRepo.updateExam(
|
||||
id,
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
JSON.stringify(exam),
|
||||
tags
|
||||
)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
@@ -89,4 +100,121 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/share
|
||||
router.post(
|
||||
'/:id/share',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const token = exam.share_token ?? crypto.randomUUID().replace(/-/g, '')
|
||||
const mode = exam.share_mode ?? 'clone'
|
||||
examRepo.setShareToken(id, req.user!.userId, token, mode)
|
||||
res.json({ shareToken: token, mode })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/exams/:id/share-mode
|
||||
router.put(
|
||||
'/:id/share-mode',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(shareModeSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setShareMode(id, req.user!.userId, req.body.mode)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/cloned
|
||||
router.get(
|
||||
'/cloned',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exams = examRepo.listClonedExams(req.user!.userId)
|
||||
res.json(exams)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/batch-delete
|
||||
router.post(
|
||||
'/batch-delete',
|
||||
validateBody(batchDeleteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const ids: number[] = req.body.ids
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
if (examRepo.deleteExam(id, req.user!.userId)) {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
res.json({ deleted })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/unshare
|
||||
router.post(
|
||||
'/:id/unshare',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const cleared = examRepo.clearShareToken(id, req.user!.userId)
|
||||
if (!cleared) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// PATCH /api/exams/:id/favorite
|
||||
router.patch(
|
||||
'/:id/favorite',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(favoriteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setFavorite(id, req.user!.userId, req.body.isFavorite)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/:id/export
|
||||
router.get(
|
||||
'/:id/export',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const raw = examRepo.getExamRawJson(id, req.user!.userId)
|
||||
if (!raw) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(raw.title)}.json"`)
|
||||
res.send(raw.exam_json)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/duplicate
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const { id: newId } = examRepo.duplicateOwnExam(req.user!.userId, exam)
|
||||
res.status(201).json({ id: newId, title: exam.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -4,7 +4,9 @@ import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as resultRepo from '../repositories/resultRepo.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
|
||||
import { analyzeResults } from '../services/studyCoach.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
@@ -78,4 +80,50 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/analyze
|
||||
router.get(
|
||||
'/analyze',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const rawResults = resultRepo.getResultsWithExamJson(req.user!.userId, 200)
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
|
||||
const analysis = await analyzeResults(rawResults, lang)
|
||||
|
||||
// Find weakest exam (lowest score that still exists in workspace)
|
||||
let weakestExamId: number | null = null
|
||||
if (rawResults.length > 0) {
|
||||
const sorted = [...rawResults].sort((a, b) => {
|
||||
const pa = a.total_points > 0 ? a.score_points / a.total_points : 0
|
||||
const pb = b.total_points > 0 ? b.score_points / b.total_points : 0
|
||||
return pa - pb
|
||||
})
|
||||
for (const r of sorted) {
|
||||
if (r.exam_id) {
|
||||
const exam = examRepo.getExamById(r.exam_id, req.user!.userId)
|
||||
if (exam) {
|
||||
weakestExamId = r.exam_id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...analysis, weakestExamId })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/trends
|
||||
router.get(
|
||||
'/trends',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const raw = resultRepo.getScoreTrends(req.user!.userId, 30)
|
||||
const trends = raw.map((r) => ({
|
||||
completed_at: r.completed_at,
|
||||
score_pct: r.total_points > 0 ? Math.round((r.score_points / r.total_points) * 100) : 0,
|
||||
title: r.title,
|
||||
exam_id: r.exam_id,
|
||||
}))
|
||||
res.json(trends)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/share/:token — public, no auth required
|
||||
router.get(
|
||||
'/:token',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const exam = examRepo.getExamByShareToken(token)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const parsed = JSON.parse(exam.exam_json)
|
||||
res.json({
|
||||
title: exam.title,
|
||||
subtitle: exam.subtitle,
|
||||
questionCount: JSON.parse(exam.exam_json).questions?.length ?? 0,
|
||||
exam: parsed,
|
||||
shareMode: exam.share_mode ?? 'clone',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/share/:token/clone — requires auth
|
||||
router.post(
|
||||
'/:token/clone',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const source = examRepo.getExamByShareToken(token)
|
||||
if (!source) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
|
||||
res.status(201).json({ id, title: source.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,165 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
avgScore: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface StudyCoachResult {
|
||||
weakAreas: WeakArea[]
|
||||
studyTips: string[]
|
||||
streakDays: number
|
||||
}
|
||||
|
||||
interface ResultSummary {
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
completedAt: number
|
||||
categories: Record<string, { score: number; total: number }>
|
||||
}
|
||||
|
||||
function getClient(): OpenAI | null {
|
||||
const provider = getSetting('ai_provider')
|
||||
const apiKey = getSetting('ai_api_key')
|
||||
if (!provider || !apiKey) return null
|
||||
|
||||
const customBase = getSetting('ai_base_url')
|
||||
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
|
||||
return new OpenAI({ apiKey, baseURL })
|
||||
}
|
||||
|
||||
function calculateStreak(results: Array<{ completed_at: number }>): number {
|
||||
if (results.length === 0) return 0
|
||||
const days = new Set<string>()
|
||||
for (const r of results) {
|
||||
const d = new Date(r.completed_at * 1000)
|
||||
days.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
const sorted = Array.from(days).sort()
|
||||
if (sorted.length === 0) return 0
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
|
||||
|
||||
let streak = 0
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const expected = new Date(today)
|
||||
expected.setDate(today.getDate() - (sorted.length - 1 - i))
|
||||
const expectedStr = `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`
|
||||
if (sorted[i] === expectedStr || (i === sorted.length - 1 && sorted[i] === todayStr)) {
|
||||
streak++
|
||||
} else if (i === sorted.length - 1) {
|
||||
// Most recent is not today — check if it was yesterday
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const yestStr = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`
|
||||
if (sorted[i] === yestStr) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
export async function analyzeResults(
|
||||
results: Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
}>,
|
||||
language = 'en',
|
||||
): Promise<StudyCoachResult> {
|
||||
const streakDays = calculateStreak(results)
|
||||
|
||||
// Build per-category stats from exam_json
|
||||
const categoryStats: Record<string, { score: number; total: number; attempts: number }> = {}
|
||||
for (const r of results) {
|
||||
let exam: { questions?: Array<{ category?: string; points?: number }> } | undefined
|
||||
try {
|
||||
exam = JSON.parse(r.exam_json)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const questions = exam?.questions ?? []
|
||||
// We don't have per-question results in the stored result, so we approximate
|
||||
// using the overall score distributed across categories proportionally
|
||||
const totalQuestionPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0)
|
||||
if (totalQuestionPoints === 0) continue
|
||||
const overallRatio = r.total_points > 0 ? r.score_points / r.total_points : 0
|
||||
for (const q of questions) {
|
||||
const cat = q.category ?? 'Uncategorized'
|
||||
const pts = q.points ?? 1
|
||||
if (!categoryStats[cat]) {
|
||||
categoryStats[cat] = { score: 0, total: 0, attempts: 0 }
|
||||
}
|
||||
categoryStats[cat].score += pts * overallRatio
|
||||
categoryStats[cat].total += pts
|
||||
}
|
||||
// Mark attempt for each unique category in this exam
|
||||
const catsInExam = new Set(questions.map((q) => q.category ?? 'Uncategorized'))
|
||||
for (const cat of catsInExam) {
|
||||
categoryStats[cat].attempts++
|
||||
}
|
||||
}
|
||||
|
||||
const weakAreas: WeakArea[] = Object.entries(categoryStats)
|
||||
.map(([category, stats]) => ({
|
||||
category,
|
||||
avgScore: stats.total > 0 ? Math.round((stats.score / stats.total) * 100) : 0,
|
||||
attempts: stats.attempts,
|
||||
}))
|
||||
.filter((w) => w.attempts >= 1)
|
||||
.sort((a, b) => a.avgScore - b.avgScore)
|
||||
.slice(0, 5)
|
||||
|
||||
const client = getClient()
|
||||
if (!client || weakAreas.length === 0) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const model = getSetting('ai_model') || 'gpt-4o-mini'
|
||||
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
|
||||
- "studyTips": string[] (2-3 short tips in ${language})
|
||||
|
||||
Do not include any text outside the JSON object.
|
||||
|
||||
Weak areas (score %, attempts):
|
||||
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
|
||||
|
||||
try {
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 400,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const tips = Array.isArray(parsed.studyTips)
|
||||
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
|
||||
return { weakAreas, studyTips: tips, streakDays }
|
||||
} catch {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const updateCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const collectionIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const moveExamSchema = z.object({
|
||||
collectionId: z.number().nullable().optional(),
|
||||
})
|
||||
@@ -80,14 +80,28 @@ export const examSchema = z.object({
|
||||
|
||||
export const saveExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const updateExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const examIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const favoriteSchema = z.object({
|
||||
isFavorite: z.boolean(),
|
||||
})
|
||||
|
||||
export const shareModeSchema = z.object({
|
||||
mode: z.enum(['view', 'clone']),
|
||||
})
|
||||
|
||||
export const batchDeleteSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
})
|
||||
|
||||
export type ExamInput = z.infer<typeof examSchema>
|
||||
Reference in new issue
Block a user