'use strict' const Database = require('better-sqlite3') const path = require('path') function initDb(dbPath) { const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db') const instance = new Database(resolvedPath) instance.pragma('journal_mode = WAL') instance.pragma('foreign_keys = ON') instance.exec(` CREATE TABLE IF NOT EXISTS profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, display_name TEXT, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS magic_tokens ( token TEXT PRIMARY KEY, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_progress ( profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, level INTEGER NOT NULL DEFAULT 1, score INTEGER NOT NULL DEFAULT 0, streak INTEGER NOT NULL DEFAULT 0, best_streak INTEGER NOT NULL DEFAULT 0, total_correct INTEGER NOT NULL DEFAULT 0, total_attempts INTEGER NOT NULL DEFAULT 0, last_session_at INTEGER, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_mnemonics ( profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, letter TEXT NOT NULL, phrase TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (profile_id, letter) ); CREATE TABLE IF NOT EXISTS letter_stats ( profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, letter TEXT NOT NULL, correct INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (profile_id, letter) ); `) function createProfile(email, displayName) { const now = Date.now() const stmt = instance.prepare( 'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *' ) return stmt.get(email, displayName || null, now, now) } function findProfileByEmail(email) { return instance.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null } function findProfileById(id) { return instance.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null } function updateProfileSeen(id) { instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id) } function createMagicToken(token, profileId, expiresAt) { instance.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run( token, profileId, expiresAt ) } function useMagicToken(token) { const row = instance .prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?') .get(token, Date.now()) if (!row) return null instance.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token) return row.profile_id } function createSession(token, profileId) { const now = Date.now() instance.prepare( 'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)' ).run(token, profileId, now, now) } function getSession(token) { const row = instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token) if (!row) return null instance.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), token) return instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token) } function deleteSession(token) { instance.prepare('DELETE FROM sessions WHERE token = ?').run(token) } function getOrCreateProgress(profileId) { const existing = instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) if (existing) return existing const now = Date.now() instance.prepare( `INSERT INTO user_progress (profile_id, level, score, streak, best_streak, total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)` ).run(profileId, now) return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) } function updateLevel(profileId, level) { getOrCreateProgress(profileId) instance.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run( level, Date.now(), profileId ) } function recordAnswer(profileId, letter, correct) { letter = letter.toUpperCase() const progress = getOrCreateProgress(profileId) const now = Date.now() const isFirstToday = !progress.last_session_at || new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString() const base = progress.level * 10 const newStreak = correct ? progress.streak + 1 : 0 const streakMult = newStreak >= 10 ? 2 : 1 const dailyMult = isFirstToday && correct ? 1.25 : 1 const points = correct ? Math.floor(base * streakMult * dailyMult) : 0 const newBestStreak = Math.max(progress.best_streak, newStreak) instance.prepare( `UPDATE user_progress SET score = score + ?, streak = ?, best_streak = ?, total_correct = total_correct + ?, total_attempts = total_attempts + 1, last_session_at = ?, updated_at = ? WHERE profile_id = ?` ).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId) // Update letter stats const existing = instance .prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?') .get(profileId, letter) if (existing) { instance.prepare( 'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?' ).run(correct ? 1 : 0, now, profileId, letter) } else { instance.prepare( 'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)' ).run(profileId, letter, correct ? 1 : 0, now) } return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) } function getMnemonics(profileId) { const rows = instance .prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?') .all(profileId) return Object.fromEntries(rows.map(r => [r.letter, r.phrase])) } function saveMnemonic(profileId, letter, phrase) { const now = Date.now() const upperLetter = letter.toUpperCase() instance.prepare( `INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?) ON CONFLICT(profile_id, letter) DO UPDATE SET phrase = excluded.phrase` ).run(profileId, upperLetter, phrase, now) // Only award 50 pts on first save — if created_at = now, it was just inserted const row = instance.prepare( 'SELECT created_at FROM user_mnemonics WHERE profile_id = ? AND letter = ?' ).get(profileId, upperLetter) if (row.created_at === now) { getOrCreateProgress(profileId) instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?') .run(now, profileId) } } function getLetterStats(profileId) { const rows = instance .prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?') .all(profileId) return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }])) } function getAdminUsers() { return instance.prepare(` SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen, pr.level, pr.score, pr.streak, pr.best_streak, pr.total_correct, pr.total_attempts FROM profiles p LEFT JOIN user_progress pr ON pr.profile_id = p.id ORDER BY p.created_at DESC `).all() } function getAdminStats() { const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0 const mostMissed = instance.prepare(` SELECT letter, SUM(attempts - correct) as misses FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5 `).all() return { totalUsers, avgScore: Math.round(avgScore), mostMissed } } return { _db: instance, createProfile, findProfileByEmail, findProfileById, updateProfileSeen, createMagicToken, useMagicToken, createSession, getSession, deleteSession, getOrCreateProgress, updateLevel, recordAnswer, getMnemonics, saveMnemonic, getLetterStats, getAdminUsers, getAdminStats, } } module.exports = { initDb }