Public Access
feat: initialize database and authentication routes in backend
This commit is contained in:
1 parent
be69db04a5
commit
eb013d4978
22 files changed
+1808
-8
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
Reference in new issue
Block a user