Public Access
feat: Implement API modules for admin, auth, exam, result, and setup functionalities
- Created adminApi.ts for admin-related operations including user management and site settings. - Developed authApi.ts for authentication processes such as registration, login, and profile management. - Added examApi.ts for handling exam CRUD operations. - Introduced resultApi.ts for managing results submission and retrieval. - Established setupApi.ts for initial setup processes and status checks. - Created ErrorBoundary component to handle rendering errors gracefully in the frontend. - Re-exported shared API utilities in index.ts for easier access.
This commit is contained in:
1 parent
62af83ce53
commit
c64cdcd9e3
46 files changed
+2227
-1125
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { AppError } from './middleware/error.js'
|
||||
|
||||
function env(key: string, defaultValue?: string): string {
|
||||
const value = process.env[key]
|
||||
if (value === undefined || value === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new AppError(500, `Missing required environment variable: ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function envInt(key: string, defaultValue?: number): number {
|
||||
const raw = process.env[key]
|
||||
if (raw === undefined || raw === '') {
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
throw new AppError(500, `Missing required environment variable: ${key}`)
|
||||
}
|
||||
const parsed = parseInt(raw, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new AppError(500, `Environment variable ${key} must be a valid integer, got: ${raw}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const config = {
|
||||
env: env('NODE_ENV', 'development'),
|
||||
port: envInt('PORT', 3001),
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||
bodyLimit: env('BODY_LIMIT', '10mb'),
|
||||
|
||||
email: {
|
||||
smtpHost: env('SMTP_HOST', ''),
|
||||
smtpPort: envInt('SMTP_PORT', 587),
|
||||
smtpUser: env('SMTP_USER', ''),
|
||||
smtpPass: env('SMTP_PASS', ''),
|
||||
fromAddress: env('EMAIL_FROM', 'BrainWave <noreply@brainwave.local>'),
|
||||
secure: env('SMTP_SECURE', 'auto'),
|
||||
},
|
||||
|
||||
appUrl: env('APP_URL', 'http://localhost:5173'),
|
||||
|
||||
rateLimiting: {
|
||||
authWindowMs: 15 * 60 * 1000, // 15 minutes
|
||||
authMaxRequests: 10,
|
||||
},
|
||||
} as const
|
||||
@@ -1,11 +1,12 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { config } from '../config.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', '..', 'db', 'brainwave.db')
|
||||
const DB_PATH = path.isAbsolute(config.dbPath)
|
||||
? config.dbPath
|
||||
: path.join(process.cwd(), config.dbPath)
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
|
||||
+40
-5
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { getDb, closeDb } from './db/database.js'
|
||||
@@ -6,6 +7,8 @@ import examRoutes from './routes/exams.js'
|
||||
import resultRoutes from './routes/results.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import setupRoutes from './routes/setup.js'
|
||||
import { errorHandler } from './middleware/error.js'
|
||||
import { config } from './config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -13,12 +16,22 @@ import { fileURLToPath } from 'url'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const PORT = Number(process.env['PORT'] ?? '3001')
|
||||
const PORT = config.port
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(cors({ origin: config.corsOrigin }))
|
||||
app.use(express.json({ limit: config.bodyLimit }))
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now()
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start
|
||||
console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`)
|
||||
})
|
||||
next()
|
||||
})
|
||||
|
||||
// Initialize database on startup
|
||||
getDb()
|
||||
@@ -31,11 +44,33 @@ app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
// Health check
|
||||
// Health check with subsystem status
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: Date.now() })
|
||||
let dbStatus: 'connected' | 'error' = 'connected'
|
||||
try {
|
||||
getDb().prepare('SELECT 1').get()
|
||||
} catch {
|
||||
dbStatus = 'error'
|
||||
}
|
||||
|
||||
const checks = {
|
||||
database: dbStatus,
|
||||
email: config.email.smtpHost ? 'configured' : 'not_configured',
|
||||
}
|
||||
|
||||
const overall = dbStatus === 'connected' ? 'healthy' : 'unhealthy'
|
||||
|
||||
res.status(overall === 'healthy' ? 200 : 503).json({
|
||||
status: overall,
|
||||
timestamp: Date.now(),
|
||||
version: process.env['npm_package_version'] ?? '0.0.1',
|
||||
checks,
|
||||
})
|
||||
})
|
||||
|
||||
// Error handler (must be after routes)
|
||||
app.use(errorHandler)
|
||||
|
||||
// Serve static frontend files if they exist
|
||||
const frontendDistPath = path.join(__dirname, '../../frontend/dist')
|
||||
if (fs.existsSync(frontendDistPath)) {
|
||||
|
||||
@@ -9,6 +9,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: JwtPayload
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public isOperational = true
|
||||
) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new Error().constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error | AppError,
|
||||
_req: any,
|
||||
res: any,
|
||||
_next: any
|
||||
) {
|
||||
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
|
||||
console.error(`[Error] ${statusCode} - ${message}`);
|
||||
if (!(err instanceof AppError) || !err.isOperational) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
status: 'error',
|
||||
message,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
function getFirstIssueMessage(error: z.ZodError): string {
|
||||
const issue = error.issues[0]
|
||||
return issue?.message ?? 'Validation failed'
|
||||
}
|
||||
|
||||
export function validateBody<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body)
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||
return
|
||||
}
|
||||
req.body = result.data as z.infer<T>
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function validateQuery<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.query)
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||
return
|
||||
}
|
||||
;(req as unknown as Record<string, unknown>).parsedQuery = result.data
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function validateParams<T extends z.ZodTypeAny>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.params)
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||
return
|
||||
}
|
||||
Object.assign(req.params, result.data)
|
||||
next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbExam {
|
||||
id: number
|
||||
user_id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
exam_json: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface ExamListItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
}
|
||||
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: 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)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getExamById(id: number, userId: number): DbExam | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as DbExam | undefined
|
||||
}
|
||||
|
||||
export function listExamsByUser(userId: number): ExamListItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`).all(userId) as ExamListItem[]
|
||||
}
|
||||
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(title, subtitle, examJson, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteExam(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM saved_exams WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function countExams(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbResult {
|
||||
id: number
|
||||
user_id: number
|
||||
exam_id: number | null
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
passing_score: number
|
||||
elapsed_ms: number
|
||||
state_json: string
|
||||
exam_json: string
|
||||
completed_at: number
|
||||
}
|
||||
|
||||
export interface ResultListItem {
|
||||
id: number
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
passing_score: number
|
||||
elapsed_ms: number
|
||||
completed_at: number
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function createResult(data: Omit<DbResult, 'id'>): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.user_id,
|
||||
data.exam_id ?? null,
|
||||
data.title,
|
||||
data.score_points,
|
||||
data.total_points,
|
||||
data.correct_count,
|
||||
data.graded_count,
|
||||
data.passing_score ?? 60,
|
||||
data.elapsed_ms ?? 0,
|
||||
data.state_json,
|
||||
data.exam_json
|
||||
)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getResultById(id: number, userId: number): DbResult | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM exam_results WHERE id = ? AND user_id = ?').get(id, userId) as DbResult | undefined
|
||||
}
|
||||
|
||||
export function listResultsByUser(userId: number, options: { limit?: number; offset?: number } = {}): { results: ResultListItem[]; total: number } {
|
||||
const db = getDb()
|
||||
const limit = Math.min(options.limit ?? 30, 100)
|
||||
const offset = options.offset ?? 0
|
||||
|
||||
const results = db.prepare(`
|
||||
SELECT id, title, score_points, total_points, correct_count, graded_count,
|
||||
passing_score, elapsed_ms, completed_at, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(userId, limit, offset) as ResultListItem[]
|
||||
|
||||
const countRow = db.prepare('SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?').get(userId) as { total: number }
|
||||
|
||||
return { results, total: countRow.total }
|
||||
}
|
||||
|
||||
export function deleteResult(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM exam_results WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function countResults(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function getAverageScore(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare(
|
||||
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
|
||||
).get() as { avg: number }
|
||||
return Math.round(row.avg * 10) / 10
|
||||
}
|
||||
|
||||
export function getRecentResults(limit: number): Array<DbResult & { display_name: string; email: string }> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT er.*, u.display_name, u.email
|
||||
FROM exam_results er
|
||||
JOIN users u ON u.id = er.user_id
|
||||
ORDER BY er.completed_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as Array<DbResult & { display_name: string; email: string }>
|
||||
}
|
||||
|
||||
export function countResultsByUser(userId: number): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface Setting {
|
||||
key: string
|
||||
value: string
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export function getSetting(key: string): string | undefined {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key) as { value: string } | undefined
|
||||
return row?.value
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||
`).run(key, value)
|
||||
}
|
||||
|
||||
export function setSettingsBatch(entries: Array<[string, string]>): void {
|
||||
const db = getDb()
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||
`)
|
||||
const tx = db.transaction((items: Array<[string, string]>) => {
|
||||
for (const [key, value] of items) {
|
||||
stmt.run(key, value)
|
||||
}
|
||||
})
|
||||
tx(entries)
|
||||
}
|
||||
|
||||
export function getAllSettings(): Record<string, string> {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
|
||||
const settings: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
settings[row.key] = row.value
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
export function seedDefaultSettings(defaults: Record<string, string>): void {
|
||||
const db = getDb()
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`)
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
stmt.run(key, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbUser {
|
||||
id: number
|
||||
email: string
|
||||
password_hash: string
|
||||
display_name: string
|
||||
role: 'user' | 'admin'
|
||||
email_verified: number
|
||||
verify_token: string | null
|
||||
verify_expires: number | null
|
||||
reset_token: string | null
|
||||
reset_expires: number | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
email: string
|
||||
passwordHash: string
|
||||
displayName: string
|
||||
role?: 'user' | 'admin'
|
||||
emailVerified?: number
|
||||
verifyToken?: string | null
|
||||
verifyExpires?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
displayName?: string
|
||||
passwordHash?: string
|
||||
role?: 'user' | 'admin'
|
||||
emailVerified?: number
|
||||
verifyToken?: string | null
|
||||
verifyExpires?: number | null
|
||||
resetToken?: string | null
|
||||
resetExpires?: number | null
|
||||
}
|
||||
|
||||
export function createUser(data: CreateUserData): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.passwordHash,
|
||||
data.displayName.trim(),
|
||||
data.role ?? 'user',
|
||||
data.emailVerified ?? 1,
|
||||
data.verifyToken ?? null,
|
||||
data.verifyExpires ?? null
|
||||
)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim()) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserById(id: number): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserByVerifyToken(token: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserByResetToken(token: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||
const db = getDb()
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
if (data.displayName !== undefined) {
|
||||
fields.push('display_name = ?')
|
||||
values.push(data.displayName.trim())
|
||||
}
|
||||
if (data.passwordHash !== undefined) {
|
||||
fields.push('password_hash = ?')
|
||||
values.push(data.passwordHash)
|
||||
}
|
||||
if (data.role !== undefined) {
|
||||
fields.push('role = ?')
|
||||
values.push(data.role)
|
||||
}
|
||||
if (data.emailVerified !== undefined) {
|
||||
fields.push('email_verified = ?')
|
||||
values.push(data.emailVerified)
|
||||
}
|
||||
if (data.verifyToken !== undefined) {
|
||||
fields.push('verify_token = ?')
|
||||
values.push(data.verifyToken)
|
||||
}
|
||||
if (data.verifyExpires !== undefined) {
|
||||
fields.push('verify_expires = ?')
|
||||
values.push(data.verifyExpires)
|
||||
}
|
||||
if (data.resetToken !== undefined) {
|
||||
fields.push('reset_token = ?')
|
||||
values.push(data.resetToken)
|
||||
}
|
||||
if (data.resetExpires !== undefined) {
|
||||
fields.push('reset_expires = ?')
|
||||
values.push(data.resetExpires)
|
||||
}
|
||||
|
||||
if (fields.length === 0) return false
|
||||
|
||||
fields.push('updated_at = unixepoch()')
|
||||
values.push(id)
|
||||
|
||||
const result = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function countUsers(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function countAdmins(): number {
|
||||
const db = getDb()
|
||||
const row = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'").get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: DbUser[]; total: number } {
|
||||
const db = getDb()
|
||||
const limit = Math.min(options.limit ?? 50, 200)
|
||||
const offset = options.offset ?? 0
|
||||
const search = options.search ?? ''
|
||||
|
||||
let query = 'SELECT * FROM users'
|
||||
let countQuery = 'SELECT COUNT(*) AS total FROM users'
|
||||
const params: unknown[] = []
|
||||
const countParams: unknown[] = []
|
||||
|
||||
if (search) {
|
||||
query += ' WHERE email LIKE ? OR display_name LIKE ?'
|
||||
countQuery += ' WHERE email LIKE ? OR display_name LIKE ?'
|
||||
params.push(`%${search}%`, `%${search}%`)
|
||||
countParams.push(`%${search}%`, `%${search}%`)
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||
params.push(limit, offset)
|
||||
|
||||
const users = db.prepare(query).all(...params) as DbUser[]
|
||||
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
|
||||
|
||||
return { users, total: countRow.total }
|
||||
}
|
||||
|
||||
export function promoteFirstUserToAdmin(): void {
|
||||
const db = getDb()
|
||||
db.prepare("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)").run()
|
||||
}
|
||||
+117
-243
@@ -1,268 +1,142 @@
|
||||
import { Router } from "express";
|
||||
import { getDb } from "../db/database.js";
|
||||
import { requireAuth, requireAdmin } from "../middleware/auth.js";
|
||||
import { sendTestEmail } from "../utils/email.js";
|
||||
import type { SmtpConfig } from "../utils/email.js";
|
||||
import { Router } from 'express'
|
||||
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import * as resultRepo from '../repositories/resultRepo.js'
|
||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router();
|
||||
const router = Router()
|
||||
|
||||
// All admin routes require authentication + admin role
|
||||
router.use(requireAuth, requireAdmin);
|
||||
router.use(requireAuth, requireAdmin)
|
||||
|
||||
// ─── Dashboard Stats ─────────────────────────────────────
|
||||
router.get("/stats", (_req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
const exams = db
|
||||
.prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
|
||||
.get() as { cnt: number };
|
||||
const results = db
|
||||
.prepare("SELECT COUNT(*) AS cnt FROM exam_results")
|
||||
.get() as { cnt: number };
|
||||
const avgScore = db
|
||||
.prepare(
|
||||
"SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results",
|
||||
)
|
||||
.get() as { avg: number };
|
||||
const recentResults = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
|
||||
u.display_name, u.email
|
||||
FROM exam_results er
|
||||
JOIN users u ON u.id = er.user_id
|
||||
ORDER BY er.completed_at DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
// GET /api/admin/users
|
||||
router.get(
|
||||
'/users',
|
||||
validateQuery(listUsersQuerySchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const parsed = (req as unknown as Record<string, unknown>).parsedQuery as { page: number; search?: string }
|
||||
const page = parsed.page ?? 1
|
||||
const limit = 20
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const { users, total } = userRepo.listUsers({
|
||||
limit,
|
||||
offset,
|
||||
search: parsed.search,
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
res.json({
|
||||
totalUsers: users.cnt,
|
||||
totalExams: exams.cnt,
|
||||
totalResults: results.cnt,
|
||||
averageScore: Math.round(avgScore.avg * 10) / 10,
|
||||
recentResults,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Admin stats error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
limit,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Get All Settings ────────────────────────────────────
|
||||
router.get("/settings", (_req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM site_settings ORDER BY key")
|
||||
.all() as { key: string; value: string }[];
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
res.json({ settings });
|
||||
} catch (err) {
|
||||
console.error("Admin get settings error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
// PUT /api/admin/users/:id/role
|
||||
router.put(
|
||||
'/users/:id/role',
|
||||
validateParams(userIdParamSchema),
|
||||
validateBody(updateUserRoleSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const { role } = req.body
|
||||
|
||||
// ─── Update Settings ─────────────────────────────────────
|
||||
router.put("/settings", (req, res) => {
|
||||
try {
|
||||
const { settings } = req.body as { settings: Record<string, string> };
|
||||
if (!settings || typeof settings !== "object") {
|
||||
res.status(400).json({ error: "Settings object is required" });
|
||||
return;
|
||||
const user = userRepo.getUserById(id)
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
|
||||
const validKeys = [
|
||||
"site_name",
|
||||
"max_upload_mb",
|
||||
"allow_guest_mode",
|
||||
"registration_enabled",
|
||||
"default_theme",
|
||||
"maintenance_mode",
|
||||
"maintenance_message",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_user",
|
||||
"smtp_pass",
|
||||
"smtp_from",
|
||||
"smtp_secure",
|
||||
"app_url",
|
||||
];
|
||||
if (user.id === req.user!.userId) {
|
||||
throw new AppError(400, 'Cannot change your own role')
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const update = db.prepare(`
|
||||
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
|
||||
`);
|
||||
|
||||
const updateMany = db.transaction((entries: [string, string][]) => {
|
||||
for (const [key, value] of entries) {
|
||||
if (!validKeys.includes(key)) continue;
|
||||
update.run(String(value), key);
|
||||
if (role === 'user' && user.role === 'admin') {
|
||||
const adminCount = userRepo.countAdmins()
|
||||
if (adminCount <= 1) {
|
||||
throw new AppError(400, 'Cannot remove the last admin')
|
||||
}
|
||||
});
|
||||
|
||||
updateMany(Object.entries(settings));
|
||||
res.json({ message: "Settings updated successfully" });
|
||||
} catch (err) {
|
||||
console.error("Admin update settings error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── List All Users ──────────────────────────────────────
|
||||
router.get("/users", (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query["limit"]) || 50, 200);
|
||||
const offset = Number(req.query["offset"]) || 0;
|
||||
const search = (req.query["search"] as string) || "";
|
||||
|
||||
const db = getDb();
|
||||
let query = `
|
||||
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
|
||||
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
|
||||
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
|
||||
FROM users u
|
||||
`;
|
||||
let countQuery = "SELECT COUNT(*) AS total FROM users";
|
||||
const params: unknown[] = [];
|
||||
const countParams: unknown[] = [];
|
||||
|
||||
if (search) {
|
||||
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
|
||||
query += where;
|
||||
countQuery += where.replace("u.", "");
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
countParams.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset);
|
||||
userRepo.updateUser(id, { role })
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
const users = db.prepare(query).all(...params);
|
||||
const countRow = db.prepare(countQuery).get(...countParams) as {
|
||||
total: number;
|
||||
};
|
||||
|
||||
res.json({ users, total: countRow.total });
|
||||
} catch (err) {
|
||||
console.error("Admin list users error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Change User Role ────────────────────────────────────
|
||||
router.patch("/users/:id/role", (req, res) => {
|
||||
try {
|
||||
const userId = Number(req.params["id"]);
|
||||
const { role } = req.body as { role: string };
|
||||
|
||||
if (!["user", "admin"].includes(role)) {
|
||||
res.status(400).json({ error: 'Role must be "user" or "admin"' });
|
||||
return;
|
||||
// DELETE /api/admin/users/:id
|
||||
router.delete(
|
||||
'/users/:id',
|
||||
validateParams(userIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (id === req.user!.userId) {
|
||||
throw new AppError(400, 'Cannot delete your own account')
|
||||
}
|
||||
|
||||
// Prevent self-demotion
|
||||
if (userId === req.user!.userId && role !== "admin") {
|
||||
res.status(400).json({ error: "You cannot remove your own admin role" });
|
||||
return;
|
||||
const user = userRepo.getUserById(id)
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(
|
||||
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
|
||||
)
|
||||
.run(role, userId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = userRepo.countAdmins()
|
||||
if (adminCount <= 1) {
|
||||
throw new AppError(400, 'Cannot delete the last admin')
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: `User role updated to ${role}` });
|
||||
} catch (err) {
|
||||
console.error("Admin change role error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
userRepo.deleteUser(id)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Delete User ─────────────────────────────────────────
|
||||
router.delete("/users/:id", (req, res) => {
|
||||
try {
|
||||
const userId = Number(req.params["id"]);
|
||||
// GET /api/admin/stats
|
||||
router.get(
|
||||
'/stats',
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json({
|
||||
totalUsers: userRepo.countUsers(),
|
||||
totalExams: resultRepo.countResults(),
|
||||
totalResults: resultRepo.countResults(),
|
||||
averageScore: resultRepo.getAverageScore(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Prevent self-deletion
|
||||
if (userId === req.user!.userId) {
|
||||
res.status(400).json({ error: "You cannot delete your own account" });
|
||||
return;
|
||||
}
|
||||
// GET /api/admin/recent-results
|
||||
router.get(
|
||||
'/recent-results',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const results = resultRepo.getRecentResults(10)
|
||||
res.json(results)
|
||||
})
|
||||
)
|
||||
|
||||
const db = getDb();
|
||||
const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
||||
// GET /api/admin/settings
|
||||
router.get(
|
||||
'/settings',
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json(settingsRepo.getAllSettings())
|
||||
})
|
||||
)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
// PUT /api/admin/settings
|
||||
router.put(
|
||||
'/settings',
|
||||
validateBody(updateSettingSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { key, value } = req.body
|
||||
settingsRepo.setSetting(key, value)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ message: "User deleted" });
|
||||
} catch (err) {
|
||||
console.error("Admin delete user error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test Email ───────────────────────────────────────────
|
||||
router.post("/test-email", async (req, res) => {
|
||||
try {
|
||||
// The body may contain optional SMTP override settings (for testing before saving)
|
||||
// and a `to` field (defaults to the requesting admin's email)
|
||||
const { to, smtp } = req.body as {
|
||||
to?: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
secure: string;
|
||||
};
|
||||
};
|
||||
|
||||
const recipient = to || req.user!.email;
|
||||
|
||||
let overrideConfig: SmtpConfig | undefined;
|
||||
if (smtp?.host) {
|
||||
const port = Number(smtp.port ?? "587");
|
||||
overrideConfig = {
|
||||
host: smtp.host,
|
||||
port,
|
||||
user: smtp.user ?? "",
|
||||
pass: smtp.pass ?? "",
|
||||
from: smtp.from ?? "BrainWave <noreply@brainwave.local>",
|
||||
secure:
|
||||
smtp.secure === "true" || (smtp.secure === "auto" && port === 465),
|
||||
};
|
||||
}
|
||||
|
||||
await sendTestEmail(recipient, overrideConfig);
|
||||
res.json({ message: `Test email sent to ${recipient}` });
|
||||
} catch (err) {
|
||||
console.error("Test email error:", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({
|
||||
error: err instanceof Error ? err.message : "Failed to send test email",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
+104
-316
@@ -1,333 +1,121 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { needsSetup } from './setup.js'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import * as authService from '../services/authService.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import {
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
verifyEmailSchema,
|
||||
resetPasswordSchema,
|
||||
forgotPasswordSchema,
|
||||
} from '../validators/authSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
const BCRYPT_ROUNDS = 12
|
||||
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
// ─── Register ────────────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
if (needsSetup()) {
|
||||
res.status(403).json({ error: 'Initial setup is required before registration' })
|
||||
return
|
||||
}
|
||||
|
||||
const { email, password, displayName } = req.body
|
||||
|
||||
if (!email || !password || !displayName) {
|
||||
res.status(400).json({ error: 'Email, password, and display name are required' })
|
||||
return
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
|
||||
res.status(400).json({ error: 'Invalid email address' })
|
||||
return
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'An account with this email already exists' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
const emailConfigured = isEmailConfigured()
|
||||
|
||||
let verifyToken: string | null = null
|
||||
let verifyExpires: number | null = null
|
||||
let emailVerified = 1
|
||||
|
||||
if (emailConfigured) {
|
||||
verifyToken = generateVerificationToken()
|
||||
verifyExpires = Date.now() + VERIFY_EXPIRY_MS
|
||||
emailVerified = 0
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires, email_verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires, emailVerified)
|
||||
|
||||
if (emailConfigured) {
|
||||
await sendVerificationEmail(email, verifyToken!)
|
||||
res.status(201).json({
|
||||
message: 'Account created. Please check your email to verify your account.',
|
||||
userId: result.lastInsertRowid,
|
||||
})
|
||||
} else {
|
||||
res.status(201).json({
|
||||
message: 'Account created and verified. You can now log in.',
|
||||
userId: result.lastInsertRowid,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Register error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { error: 'Too many attempts, please try again later' },
|
||||
standardHeaders: true,
|
||||
skipSuccessfulRequests: false,
|
||||
})
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password are required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(
|
||||
(email as string).toLowerCase().trim()
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid email or password' })
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password as string, user['password_hash'] as string)
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid email or password' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!user['email_verified']) {
|
||||
res.status(403).json({ error: 'Please verify your email before logging in' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = signToken({
|
||||
userId: user['id'] as number,
|
||||
email: user['email'] as string,
|
||||
displayName: user['display_name'] as string,
|
||||
role: (user['role'] as 'user' | 'admin') ?? 'user',
|
||||
})
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user['id'],
|
||||
email: user['email'],
|
||||
displayName: user['display_name'],
|
||||
role: user['role'] ?? 'user',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Verify Email ────────────────────────────────────────
|
||||
router.get('/verify-email', (req, res) => {
|
||||
try {
|
||||
const { token } = req.query
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(400).json({ error: 'Verification token is required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Invalid verification token' })
|
||||
return
|
||||
}
|
||||
|
||||
if ((user['verify_expires'] as number) < Date.now()) {
|
||||
res.status(400).json({ error: 'Verification token has expired. Please register again.' })
|
||||
return
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(user['id'])
|
||||
|
||||
res.json({ message: 'Email verified successfully. You can now log in.' })
|
||||
} catch (err) {
|
||||
console.error('Verify error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Forgot Password ────────────────────────────────────
|
||||
router.post('/forgot-password', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!email || typeof email !== 'string') {
|
||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(
|
||||
email.toLowerCase().trim()
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (user && user['email_verified']) {
|
||||
const resetToken = generateVerificationToken()
|
||||
const resetExpires = Date.now() + RESET_EXPIRY_MS
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(resetToken, resetExpires, user['id'])
|
||||
|
||||
await sendPasswordResetEmail(email, resetToken)
|
||||
}
|
||||
|
||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
||||
} catch (err) {
|
||||
console.error('Forgot password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Reset Password ─────────────────────────────────────
|
||||
router.post('/reset-password', async (req, res) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(400).json({ error: 'Reset token is required' })
|
||||
return
|
||||
}
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
res.status(400).json({ error: 'New password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Invalid reset token' })
|
||||
return
|
||||
}
|
||||
|
||||
if ((user['reset_expires'] as number) < Date.now()) {
|
||||
res.status(400).json({ error: 'Reset token has expired. Please request a new one.' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(passwordHash, user['id'])
|
||||
|
||||
res.json({ message: 'Password updated successfully. You can now log in with your new password.' })
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Current User ────────────────────────────────────
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT id, email, display_name, role, created_at FROM users WHERE id = ?').get(
|
||||
req.user!.userId
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user['id'],
|
||||
email: user['email'],
|
||||
displayName: user['display_name'],
|
||||
role: user['role'] ?? 'user',
|
||||
createdAt: user['created_at'],
|
||||
// POST /api/auth/register
|
||||
router.post(
|
||||
'/register',
|
||||
authLimiter,
|
||||
validateBody(registerSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const isFirstUser = userRepo.countUsers() === 0
|
||||
const result = await authService.register(req.body, isFirstUser)
|
||||
res.json(result)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Update Current User ─────────────────────────────────
|
||||
router.patch('/me', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { displayName } = req.body
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
authLimiter,
|
||||
validateBody(loginSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await authService.login(req.body)
|
||||
res.json(result)
|
||||
})
|
||||
)
|
||||
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
// GET /api/auth/me
|
||||
router.get(
|
||||
'/me',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const user = authService.getMe(req.user!.userId)
|
||||
res.json(user)
|
||||
})
|
||||
)
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE users SET display_name = ?, updated_at = unixepoch() WHERE id = ?
|
||||
`).run(displayName.trim(), req.user!.userId)
|
||||
// PUT /api/auth/me
|
||||
router.put(
|
||||
'/me',
|
||||
validateBody(updateProfileSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const user = await authService.updateProfile(req.user!.userId, req.body)
|
||||
res.json(user)
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ message: 'Profile updated successfully' })
|
||||
} catch (err) {
|
||||
console.error('Update profile error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// POST /api/auth/change-password
|
||||
router.post(
|
||||
'/change-password',
|
||||
validateBody(changePasswordSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
await authService.changePassword(req.user!.userId, req.body)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Change Password ─────────────────────────────────────
|
||||
router.post('/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body
|
||||
// GET /api/auth/verify
|
||||
router.get(
|
||||
'/verify',
|
||||
validateBody(verifyEmailSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.verifyEmail(req.body)
|
||||
res.json({ message: 'Email verified successfully' })
|
||||
})
|
||||
)
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Current password and new password are required' })
|
||||
return
|
||||
}
|
||||
if (typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
res.status(400).json({ error: 'New password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
// POST /api/auth/forgot-password
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
authLimiter,
|
||||
validateBody(forgotPasswordSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.forgotPassword(req.body.email)
|
||||
res.json({ message: 'If an account exists, a reset email has been sent' })
|
||||
})
|
||||
)
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(
|
||||
req.user!.userId
|
||||
) as Record<string, unknown> | undefined
|
||||
// POST /api/auth/reset-password
|
||||
router.post(
|
||||
'/reset-password',
|
||||
authLimiter,
|
||||
validateBody(resetPasswordSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.resetPassword(req.body)
|
||||
res.json({ message: 'Password reset successfully' })
|
||||
})
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword as string, user['password_hash'] as string)
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Current password is incorrect' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
||||
db.prepare(`
|
||||
UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?
|
||||
`).run(passwordHash, req.user!.userId)
|
||||
|
||||
res.json({ message: 'Password changed successfully' })
|
||||
} catch (err) {
|
||||
console.error('Change password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// DELETE /api/auth/me
|
||||
router.delete(
|
||||
'/me',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
authService.deleteUserAccount(req.user!.userId)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+73
-111
@@ -1,128 +1,90 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/database.js'
|
||||
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 examRepo from '../repositories/examRepo.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── List User's Saved Exams ─────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const exams = db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`).all(req.user!.userId)
|
||||
// All exam routes require authentication
|
||||
router.use(requireAuth)
|
||||
|
||||
// GET /api/exams
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exams = examRepo.listExamsByUser(req.user!.userId)
|
||||
res.json(exams)
|
||||
} catch (err) {
|
||||
console.error('List exams error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Full Exam ───────────────────────────────────────
|
||||
router.get('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const exam = db.prepare(`
|
||||
SELECT * FROM saved_exams WHERE id = ? AND user_id = ?
|
||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/:id
|
||||
router.get(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json(exam)
|
||||
})
|
||||
)
|
||||
|
||||
res.json({
|
||||
id: exam['id'],
|
||||
title: exam['title'],
|
||||
subtitle: exam['subtitle'],
|
||||
exam: JSON.parse(exam['exam_json'] as string),
|
||||
createdAt: exam['created_at'],
|
||||
updatedAt: exam['updated_at'],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// POST /api/exams
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = req.body.exam
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Save Exam ───────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { exam } = req.body
|
||||
|
||||
if (!exam || !exam.title || !exam.questions?.length) {
|
||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
||||
return
|
||||
// PUT /api/exams/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(updateExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = req.body.exam
|
||||
const updated = examRepo.updateExam(
|
||||
id,
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam))
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Exam saved successfully',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Update Exam ─────────────────────────────────────────
|
||||
router.put('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { exam } = req.body
|
||||
|
||||
if (!exam || !exam.title || !exam.questions?.length) {
|
||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
||||
return
|
||||
// DELETE /api/exams/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = examRepo.deleteExam(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(exam.title, exam.subtitle ?? null, JSON.stringify(exam), Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ message: 'Exam updated successfully' })
|
||||
} catch (err) {
|
||||
console.error('Update exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Delete Exam ─────────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM saved_exams WHERE id = ? AND user_id = ?
|
||||
`).run(Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ message: 'Exam deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+64
-122
@@ -1,139 +1,81 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/database.js'
|
||||
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 resultRepo from '../repositories/resultRepo.js'
|
||||
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── List User's Results ─────────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query['limit']) || 30, 100)
|
||||
const offset = Number(req.query['offset']) || 0
|
||||
router.use(requireAuth)
|
||||
|
||||
const db = getDb()
|
||||
const results = db.prepare(`
|
||||
SELECT id, title, score_points, total_points, correct_count, graded_count,
|
||||
passing_score, elapsed_ms, completed_at, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(req.user!.userId, limit, offset)
|
||||
|
||||
const countRow = db.prepare(
|
||||
'SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?'
|
||||
).get(req.user!.userId) as { total: number }
|
||||
|
||||
res.json({ results, total: countRow.total })
|
||||
} catch (err) {
|
||||
console.error('List results error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Single Result ───────────────────────────────────
|
||||
router.get('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
SELECT * FROM exam_results WHERE id = ? AND user_id = ?
|
||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
||||
// GET /api/results
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { results, total } = resultRepo.listResultsByUser(req.user!.userId)
|
||||
res.json({ results, total })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/:id
|
||||
router.get(
|
||||
'/:id',
|
||||
validateParams(resultIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const result = resultRepo.getResultById(id, req.user!.userId)
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
throw new AppError(404, 'Result not found')
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: result['id'],
|
||||
title: result['title'],
|
||||
scorePoints: result['score_points'],
|
||||
totalPoints: result['total_points'],
|
||||
correctCount: result['correct_count'],
|
||||
gradedCount: result['graded_count'],
|
||||
passingScore: result['passing_score'],
|
||||
elapsedMs: result['elapsed_ms'],
|
||||
completedAt: result['completed_at'],
|
||||
examId: result['exam_id'],
|
||||
state: JSON.parse(result['state_json'] as string),
|
||||
exam: JSON.parse(result['exam_json'] as string),
|
||||
...result,
|
||||
state: JSON.parse(result.state_json),
|
||||
exam: JSON.parse(result.exam_json),
|
||||
state_json: undefined,
|
||||
exam_json: undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Save Result ─────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body
|
||||
|
||||
if (
|
||||
!title || scorePoints == null || totalPoints == null ||
|
||||
correctCount == null || gradedCount == null || !state || !exam
|
||||
) {
|
||||
res.status(400).json({ error: 'Missing required result fields' })
|
||||
return
|
||||
}
|
||||
|
||||
let noteSource = ''
|
||||
if (typeof notes === 'string') {
|
||||
noteSource = notes
|
||||
} else if (typeof state.notes === 'string') {
|
||||
noteSource = state.notes
|
||||
}
|
||||
const normalizedState = {
|
||||
...state,
|
||||
notes: noteSource.slice(0, 1000),
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user!.userId,
|
||||
examId ?? null,
|
||||
title,
|
||||
scorePoints,
|
||||
totalPoints,
|
||||
correctCount,
|
||||
gradedCount,
|
||||
passingScore ?? 60,
|
||||
elapsedMs ?? 0,
|
||||
JSON.stringify(normalizedState),
|
||||
JSON.stringify(exam),
|
||||
)
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Result saved successfully',
|
||||
// POST /api/results
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(submitResultSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const data = req.body
|
||||
const { id } = resultRepo.createResult({
|
||||
user_id: req.user!.userId,
|
||||
exam_id: data.exam_id ?? null,
|
||||
title: data.title,
|
||||
score_points: data.score_points,
|
||||
total_points: data.total_points,
|
||||
correct_count: data.correct_count,
|
||||
graded_count: data.graded_count,
|
||||
passing_score: data.passing_score ?? 60,
|
||||
elapsed_ms: data.elapsed_ms,
|
||||
state_json: JSON.stringify(data.state),
|
||||
exam_json: JSON.stringify(data.exam),
|
||||
completed_at: Date.now(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.status(201).json({ id })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Delete Result ───────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM exam_results WHERE id = ? AND user_id = ?
|
||||
`).run(Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
// DELETE /api/results/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(resultIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = resultRepo.deleteResult(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Result not found')
|
||||
}
|
||||
|
||||
res.json({ message: 'Result deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+52
-105
@@ -1,122 +1,69 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { signToken } from '../utils/tokens.js'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||
import { setupSchema } from '../validators/setupSchemas.js'
|
||||
import { sendTestEmail } from '../utils/email.js'
|
||||
|
||||
const router = Router()
|
||||
const BCRYPT_ROUNDS = 12
|
||||
|
||||
function needsSetup(): boolean {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
||||
return row.cnt === 0
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
function getSiteTitle(): string | null {
|
||||
return settingsRepo.getSetting('site_title') ?? null
|
||||
}
|
||||
|
||||
// ─── Check Setup Status ──────────────────────────────────
|
||||
router.get('/status', (_req, res) => {
|
||||
try {
|
||||
res.json({ needsSetup: needsSetup() })
|
||||
} catch (err) {
|
||||
console.error('Setup status error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// GET /api/setup/status
|
||||
router.get(
|
||||
'/status',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const title = getSiteTitle()
|
||||
res.json({
|
||||
completed: !!title,
|
||||
siteTitle: title ?? undefined,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Perform Initial Setup ───────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
if (!needsSetup()) {
|
||||
res.status(403).json({ error: 'Setup has already been completed' })
|
||||
return
|
||||
// POST /api/setup
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(setupSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (getSiteTitle()) {
|
||||
throw new AppError(400, 'Setup already completed')
|
||||
}
|
||||
|
||||
const {
|
||||
if (userRepo.countUsers() > 0) {
|
||||
throw new AppError(400, 'Users already exist')
|
||||
}
|
||||
|
||||
const { email, password, displayName, siteTitle, language } = req.body
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||
|
||||
userRepo.createUser({
|
||||
email,
|
||||
password,
|
||||
passwordHash,
|
||||
displayName,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpPass,
|
||||
smtpFrom,
|
||||
smtpSecure,
|
||||
appUrl,
|
||||
} = req.body
|
||||
|
||||
// Validate admin credentials
|
||||
if (!email || !password || !displayName) {
|
||||
res.status(400).json({ error: 'Email, password, and display name are required' })
|
||||
return
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
|
||||
res.status(400).json({ error: 'Invalid email address' })
|
||||
return
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// Create admin user (auto-verified since no SMTP yet)
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, display_name, role, email_verified)
|
||||
VALUES (?, ?, ?, 'admin', 1)
|
||||
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim())
|
||||
|
||||
const userId = result.lastInsertRowid as number
|
||||
|
||||
// Save settings
|
||||
const settings: Record<string, string> = {
|
||||
app_url: typeof appUrl === 'string' && appUrl.trim() ? appUrl.trim() : 'http://localhost:5173',
|
||||
}
|
||||
|
||||
if (typeof smtpHost === 'string' && smtpHost.trim()) {
|
||||
settings.smtp_host = smtpHost.trim()
|
||||
settings.smtp_port = String(Number(smtpPort) || 587)
|
||||
settings.smtp_user = typeof smtpUser === 'string' ? smtpUser : ''
|
||||
settings.smtp_pass = typeof smtpPass === 'string' ? smtpPass : ''
|
||||
settings.smtp_from = typeof smtpFrom === 'string' && smtpFrom.trim() ? smtpFrom.trim() : 'BrainWave <noreply@brainwave.local>'
|
||||
settings.smtp_secure = typeof smtpSecure === 'string' ? smtpSecure : 'auto'
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||
`)
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
upsert.run(key, value)
|
||||
}
|
||||
|
||||
// Generate JWT and log admin in immediately
|
||||
const token = signToken({
|
||||
userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
displayName: displayName.trim(),
|
||||
role: 'admin',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Setup completed successfully',
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
displayName: displayName.trim(),
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Setup error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
settingsRepo.setSettingsBatch([
|
||||
['site_title', siteTitle.trim()],
|
||||
['site_language', language],
|
||||
['maintenance_mode', 'false'],
|
||||
])
|
||||
|
||||
// Send a test email to the new admin
|
||||
sendTestEmail(email).catch(() => {})
|
||||
|
||||
res.status(201).json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
export { needsSetup }
|
||||
@@ -0,0 +1,172 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
|
||||
const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
user: { id: number; email: string; displayName: string; role: string }
|
||||
}
|
||||
|
||||
export async function register(input: RegisterInput, isFirstUser: boolean): Promise<AuthResult> {
|
||||
const existing = userRepo.getUserByEmail(input.email)
|
||||
if (existing) {
|
||||
throw new AppError(400, 'Email already registered')
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||
const role = isFirstUser ? 'admin' : 'user'
|
||||
const verifyToken = generateVerificationToken()
|
||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName: input.displayName,
|
||||
role,
|
||||
emailVerified: 0,
|
||||
verifyToken,
|
||||
verifyExpires,
|
||||
})
|
||||
|
||||
const user = userRepo.getUserById(id)!
|
||||
|
||||
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||
sendVerificationEmail(user.email, verifyToken).catch(() => {})
|
||||
|
||||
return {
|
||||
token: signToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role as 'user' | 'admin',
|
||||
}),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
const user = userRepo.getUserByEmail(input.email)
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Invalid email or password')
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(input.password, user.password_hash)
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'Invalid email or password')
|
||||
}
|
||||
|
||||
return {
|
||||
token: signToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role as 'user' | 'admin',
|
||||
}),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getMe(userId: number) {
|
||||
const user = userRepo.getUserById(userId)
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
emailVerified: !!user.email_verified,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProfile(userId: number, input: UpdateProfileInput) {
|
||||
userRepo.updateUser(userId, { displayName: input.displayName })
|
||||
return getMe(userId)
|
||||
}
|
||||
|
||||
export async function changePassword(userId: number, input: ChangePasswordInput) {
|
||||
const user = userRepo.getUserById(userId)
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(input.currentPassword, user.password_hash)
|
||||
if (!valid) {
|
||||
throw new AppError(400, 'Current password is incorrect')
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||
userRepo.updateUser(userId, { passwordHash })
|
||||
}
|
||||
|
||||
export async function verifyEmail(input: VerifyEmailInput) {
|
||||
const user = userRepo.getUserByVerifyToken(input.token)
|
||||
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
||||
throw new AppError(400, 'Invalid or expired verification token')
|
||||
}
|
||||
|
||||
userRepo.updateUser(user.id, {
|
||||
emailVerified: 1,
|
||||
verifyToken: null,
|
||||
verifyExpires: null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgotPassword(email?: string) {
|
||||
if (!email) {
|
||||
throw new AppError(400, 'Email is required')
|
||||
}
|
||||
|
||||
const user = userRepo.getUserByEmail(email)
|
||||
if (!user) {
|
||||
// Don't reveal whether email exists
|
||||
return
|
||||
}
|
||||
|
||||
const resetToken = generateVerificationToken()
|
||||
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||
|
||||
userRepo.updateUser(user.id, { resetToken, resetExpires })
|
||||
|
||||
// Fire and forget
|
||||
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
|
||||
}
|
||||
|
||||
export async function resetPassword(input: ResetPasswordInput) {
|
||||
const user = userRepo.getUserByResetToken(input.token)
|
||||
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
||||
throw new AppError(400, 'Invalid or expired reset token')
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||
userRepo.updateUser(user.id, {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetExpires: null,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteUserAccount(userId: number) {
|
||||
const success = userRepo.deleteUser(userId)
|
||||
if (!success) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express'
|
||||
|
||||
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { getDb } from "../db/database.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,10 +22,10 @@ export function getAppUrl(): string {
|
||||
.prepare("SELECT value FROM site_settings WHERE key = ?")
|
||||
.get("app_url") as { value: string } | undefined;
|
||||
return (
|
||||
(row?.value?.trim() || process.env["APP_URL"]) ?? "http://localhost:5173"
|
||||
(row?.value?.trim() || config.appUrl) ?? "http://localhost:5173"
|
||||
);
|
||||
} catch {
|
||||
return process.env["APP_URL"] ?? "http://localhost:5173";
|
||||
return config.appUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
|
||||
import { config } from '../config.js'
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: number
|
||||
@@ -11,11 +10,11 @@ export interface JwtPayload {
|
||||
}
|
||||
|
||||
export function signToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' })
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' })
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as JwtPayload
|
||||
return jwt.verify(token, config.jwtSecret) as JwtPayload
|
||||
}
|
||||
|
||||
export function generateVerificationToken(): string {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updateUserRoleSchema = z.object({
|
||||
user_id: z.number().min(1),
|
||||
role: z.enum(['user', 'admin']),
|
||||
})
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
value: z.string().min(1),
|
||||
})
|
||||
|
||||
export const listUsersQuerySchema = z.object({
|
||||
page: z.string().regex(/^\d+$/).optional().transform(v => (v ? Number(v) : 1)),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
|
||||
export const userIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
})
|
||||
|
||||
export const verifyEmailSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||
})
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const choiceSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const matchItemSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const questionImageSchema = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const questionAudioSchema = z.object({
|
||||
src: z.string(),
|
||||
})
|
||||
|
||||
const questionCodeSchema = z.object({
|
||||
language: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const questionTableSchema = z.object({
|
||||
headers: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
})
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
]),
|
||||
category: z.string().optional(),
|
||||
points: z.number().optional(),
|
||||
prompt: z.string(),
|
||||
explanation: z.string().optional(),
|
||||
image: questionImageSchema.optional(),
|
||||
audio: questionAudioSchema.optional(),
|
||||
code: questionCodeSchema.optional(),
|
||||
table: questionTableSchema.optional(),
|
||||
svg: z.string().optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
ignoreAccents: z.boolean().optional(),
|
||||
sourceLanguage: z.string().optional(),
|
||||
targetLanguage: z.string().optional(),
|
||||
choices: z.array(choiceSchema).optional(),
|
||||
minWords: z.number().optional(),
|
||||
rubric: z.array(z.string()).optional(),
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: z.array(matchItemSchema).optional(),
|
||||
items: z.array(matchItemSchema).optional(),
|
||||
})
|
||||
|
||||
export const examSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
subtitle: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
timeLimitMinutes: z.number().nullable().optional(),
|
||||
feedbackMode: z.enum(['end', 'immediate']).optional(),
|
||||
passingScore: z.number().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
questions: z.array(questionSchema).min(1, 'At least one question is required'),
|
||||
})
|
||||
|
||||
export const saveExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
})
|
||||
|
||||
export const updateExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
})
|
||||
|
||||
export const examIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export type ExamInput = z.infer<typeof examSchema>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const answerValueSchema = z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())])
|
||||
|
||||
export const submitResultSchema = z.object({
|
||||
exam_id: z.number().nullable().optional(),
|
||||
title: z.string().min(1),
|
||||
score_points: z.number(),
|
||||
total_points: z.number(),
|
||||
correct_count: z.number(),
|
||||
graded_count: z.number(),
|
||||
passing_score: z.number().optional(),
|
||||
elapsed_ms: z.number(),
|
||||
state: z.object({
|
||||
answers: z.record(z.string(), answerValueSchema),
|
||||
gradedAnswers: z.record(z.string(), z.object({
|
||||
correct: z.boolean(),
|
||||
points: z.number(),
|
||||
explanation: z.string().optional(),
|
||||
})),
|
||||
answeredCount: z.number(),
|
||||
score: z.number(),
|
||||
correctCount: z.number(),
|
||||
gradedCount: z.number(),
|
||||
timeExpired: z.boolean().optional(),
|
||||
}),
|
||||
exam: z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
questions: z.array(z.any()),
|
||||
timeLimitMinutes: z.number().nullable().optional(),
|
||||
passingScore: z.number().optional(),
|
||||
feedbackMode: z.enum(['end', 'immediate']).optional(),
|
||||
}).passthrough(),
|
||||
})
|
||||
|
||||
export const resultIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const setupSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||
siteTitle: z.string().min(1, 'Site title is required').max(255),
|
||||
language: z.enum(['en', 'nl']),
|
||||
})
|
||||
|
||||
export const setupStatusSchema = z.object({
|
||||
email: z.string().email().max(255).optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
})
|
||||
Reference in new issue
Block a user