feat: initialize database and authentication routes in backend

This commit is contained in:
LaurensBartelds committed 2026-04-21 22:51:22 +02:00
1 parent be69db04a5
commit eb013d4978
22 files changed
+1808 -8

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import Database from 'better-sqlite3'
import path from 'path'
import { fileURLToPath } from 'url'
import { runMigrations } from './migrations.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db')
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
runMigrations(db)
}
return db
}
export function closeDb(): void {
if (db) {
db.close()
db = null
}
}
+48
View File
@@ -0,0 +1,48 @@
import type Database from 'better-sqlite3'
export function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
email_verified INTEGER DEFAULT 0,
verify_token TEXT,
verify_expires INTEGER,
reset_token TEXT,
reset_expires INTEGER,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS saved_exams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
subtitle TEXT,
exam_json TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS exam_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exam_id INTEGER REFERENCES saved_exams(id) ON DELETE SET NULL,
title TEXT NOT NULL,
score_points REAL NOT NULL,
total_points REAL NOT NULL,
correct_count INTEGER NOT NULL,
graded_count INTEGER NOT NULL,
passing_score REAL DEFAULT 60,
elapsed_ms INTEGER NOT NULL,
state_json TEXT NOT NULL,
exam_json TEXT NOT NULL,
completed_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
`)
}
+42
View File
@@ -0,0 +1,42 @@
import express from 'express'
import cors from 'cors'
import { getDb, closeDb } from './db/database.js'
import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js'
const PORT = Number(process.env['PORT'] ?? '3001')
const app = express()
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
app.use(express.json({ limit: '10mb' }))
// Initialize database on startup
getDb()
console.log('✓ Database initialized')
// Routes
app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: Date.now() })
})
const server = app.listen(PORT, () => {
console.log(`🧠 BrainWave API running on http://localhost:${PORT}`)
})
// Graceful shutdown
function shutdown() {
console.log('\nShutting down...')
server.close()
closeDb()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
+23
View File
@@ -0,0 +1,23 @@
import type { Request, Response, NextFunction } from 'express'
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
declare module 'express' {
interface Request {
user?: JwtPayload
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Authentication required' })
return
}
try {
const token = header.slice(7)
req.user = verifyToken(token)
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
+245
View File
@@ -0,0 +1,245 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { requireAuth } from '../middleware/auth.js'
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 {
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 verifyToken = generateVerificationToken()
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires)
VALUES (?, ?, ?, ?, ?)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires)
await sendVerificationEmail(email, verifyToken)
res.status(201).json({
message: 'Account created. Please check your email to verify your account.',
userId: result.lastInsertRowid,
})
} catch (err) {
console.error('Register error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── 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,
})
res.json({
token,
user: {
id: user['id'],
email: user['email'],
displayName: user['display_name'],
},
})
} 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, 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'],
createdAt: user['created_at'],
})
})
export default router
+99
View File
@@ -0,0 +1,99 @@
import { Router } from 'express'
import { getDb } from '../db/database.js'
import { requireAuth } from '../middleware/auth.js'
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)
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
if (!exam) {
res.status(404).json({ error: 'Exam not found' })
return
}
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' })
}
})
// ─── 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
}
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' })
}
})
// ─── 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' })
}
})
export default router
+128
View File
@@ -0,0 +1,128 @@
import { Router } from 'express'
import { getDb } from '../db/database.js'
import { requireAuth } from '../middleware/auth.js'
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
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
if (!result) {
res.status(404).json({ error: 'Result not found' })
return
}
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),
})
} 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, 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
}
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(state),
JSON.stringify(exam),
)
res.status(201).json({
id: result.lastInsertRowid,
message: 'Result saved successfully',
})
} catch (err) {
console.error('Save result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── 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
}
res.json({ message: 'Result deleted' })
} catch (err) {
console.error('Delete result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router
+81
View File
@@ -0,0 +1,81 @@
import nodemailer from 'nodemailer'
const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173'
const SMTP_HOST = process.env['SMTP_HOST']
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587')
const SMTP_USER = process.env['SMTP_USER']
const SMTP_PASS = process.env['SMTP_PASS']
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>'
const isDev = !SMTP_HOST
let transporter: nodemailer.Transporter | null = null
function getTransporter(): nodemailer.Transporter | null {
if (isDev) return null
if (!transporter) {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: { user: SMTP_USER, pass: SMTP_PASS },
})
}
return transporter
}
async function sendMail(to: string, subject: string, html: string): Promise<void> {
const t = getTransporter()
if (!t) {
console.log('\n══════════════════════════════════════════════════')
console.log(`📧 EMAIL (dev mode) → ${to}`)
console.log(` Subject: ${subject}`)
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`)
console.log('══════════════════════════════════════════════════\n')
return
}
await t.sendMail({ from: SMTP_FROM, to, subject, html })
}
export async function sendVerificationEmail(to: string, token: string): Promise<void> {
const link = `${APP_URL}/verify?token=${token}`
await sendMail(to, 'Verify your BrainWave account', `
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
Click the button below to verify your email address and activate your account.
</p>
<a href="${link}" style="display: inline-block; padding: 12px 28px; background: #1a1a2e; color: #fff; text-decoration: none; border-radius: 10px; font-size: 14px; font-weight: 500;">
Verify Email
</a>
<p style="font-size: 13px; color: #888; margin-top: 32px; line-height: 1.5;">
Or copy this link: <br/><code style="word-break: break-all;">${link}</code>
</p>
<p style="font-size: 12px; color: #aaa; margin-top: 24px;">
This link expires in 24 hours. If you didn't create an account, you can ignore this email.
</p>
</div>
`)
}
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
const link = `${APP_URL}/reset-password?token=${token}`
await sendMail(to, 'Reset your BrainWave password', `
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
We received a request to reset your password. Click the button below to set a new one.
</p>
<a href="${link}" style="display: inline-block; padding: 12px 28px; background: #1a1a2e; color: #fff; text-decoration: none; border-radius: 10px; font-size: 14px; font-weight: 500;">
Reset Password
</a>
<p style="font-size: 13px; color: #888; margin-top: 32px; line-height: 1.5;">
Or copy this link: <br/><code style="word-break: break-all;">${link}</code>
</p>
<p style="font-size: 12px; color: #aaa; margin-top: 24px;">
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
</p>
</div>
`)
}
+22
View File
@@ -0,0 +1,22 @@
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
export interface JwtPayload {
userId: number
email: string
displayName: string
}
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' })
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload
}
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex')
}