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:
LaurensBartelds committed 2026-04-26 20:10:46 +02:00
1 parent 62af83ce53
commit c64cdcd9e3
46 files changed
+2227 -1125

No files matched your search

+48
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -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)) {
+4
View File
@@ -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 ')) {
+30
View File
@@ -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,
});
}
+43
View File
@@ -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()
}
}
+67
View File
@@ -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
}
+112
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+171
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
+172
View File
@@ -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')
}
}
+7
View File
@@ -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)
}
}
+3 -2
View File
@@ -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;
}
}
+3 -4
View File
@@ -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 {
+20
View File
@@ -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'),
})
+41
View File
@@ -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>
+93
View File
@@ -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>
+40
View File
@@ -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'),
})
+14
View File
@@ -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(),
})