diff --git a/server/db.js b/server/db.js index cdf65fb..5b7070a 100644 --- a/server/db.js +++ b/server/db.js @@ -2,8 +2,6 @@ const Database = require('better-sqlite3') const path = require('path') -let db - function initDb(dbPath) { const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db') const instance = new Database(resolvedPath) @@ -20,18 +18,18 @@ function initDb(dbPath) { ); CREATE TABLE IF NOT EXISTS magic_tokens ( token TEXT PRIMARY KEY, - profile_id INTEGER NOT NULL, + 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, + 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, + 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, @@ -42,14 +40,14 @@ function initDb(dbPath) { updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_mnemonics ( - profile_id INTEGER NOT NULL, + 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, + 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, @@ -58,7 +56,175 @@ function initDb(dbPath) { ); `) - db = instance + 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, @@ -82,172 +248,4 @@ function initDb(dbPath) { } } -function createProfile(email, displayName) { - const now = Date.now() - const stmt = db.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 db.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null -} - -function findProfileById(id) { - return db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null -} - -function updateProfileSeen(id) { - db.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id) -} - -function createMagicToken(token, profileId, expiresAt) { - db.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run( - token, profileId, expiresAt - ) -} - -function useMagicToken(token) { - const row = db - .prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?') - .get(token, Date.now()) - if (!row) return null - db.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token) - return row.profile_id -} - -function createSession(token, profileId) { - const now = Date.now() - db.prepare( - 'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)' - ).run(token, profileId, now, now) -} - -function getSession(token) { - const row = db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) - if (!row) return null - db.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), token) - return row -} - -function deleteSession(token) { - db.prepare('DELETE FROM sessions WHERE token = ?').run(token) -} - -function getOrCreateProgress(profileId) { - const existing = db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) - if (existing) return existing - const now = Date.now() - db.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 db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) -} - -function updateLevel(profileId, level) { - db.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run( - level, Date.now(), profileId - ) -} - -function recordAnswer(profileId, letter, correct) { - 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) - - db.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 = db - .prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?') - .get(profileId, letter) - if (existing) { - db.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 { - db.prepare( - 'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)' - ).run(profileId, letter, correct ? 1 : 0, now) - } - - return db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId) -} - -function getMnemonics(profileId) { - const rows = db - .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() - const existing = db.prepare( - 'SELECT 1 FROM user_mnemonics WHERE profile_id = ? AND letter = ?' - ).get(profileId, upperLetter) - - if (existing) { - db.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?') - .run(phrase, profileId, upperLetter) - } else { - db.prepare( - 'INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)' - ).run(profileId, upperLetter, phrase, now) - // Award 50 points for first mnemonic save — ensure progress row exists first - getOrCreateProgress(profileId) - db.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?') - .run(now, profileId) - } -} - -function getLetterStats(profileId) { - const rows = db - .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 db.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 = db.prepare('SELECT COUNT(*) as c FROM profiles').get().c - const avgScore = db.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0 - const mostMissed = db.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 } -} - module.exports = { initDb } diff --git a/server/tests/db.test.js b/server/tests/db.test.js index 6aab2e6..3893332 100644 --- a/server/tests/db.test.js +++ b/server/tests/db.test.js @@ -29,6 +29,29 @@ describe('profiles', () => { test('findProfileByEmail returns null for unknown email', () => { assert.equal(db.findProfileByEmail('nobody@example.com'), null) }) + + test('findProfileById returns profile by id', () => { + const created = db.createProfile('byid@example.com', 'ById') + const found = db.findProfileById(created.id) + assert.equal(found.id, created.id) + assert.equal(found.email, 'byid@example.com') + }) + + test('findProfileById returns null for unknown id', () => { + assert.equal(db.findProfileById(999999), null) + }) + + test('updateProfileSeen updates last_seen timestamp', () => { + const created = db.createProfile('seen@example.com', 'Seen') + const before = created.last_seen + // Ensure time advances by at least 1ms + const laterTime = before + 1 + // Use a small delay approach: manipulate via direct SQL isn't needed, + // just call updateProfileSeen and verify last_seen >= original + db.updateProfileSeen(created.id) + const updated = db.findProfileById(created.id) + assert.ok(updated.last_seen >= before) + }) }) describe('magic tokens', () => { @@ -107,6 +130,19 @@ describe('progress', () => { assert.equal(p.streak, 0) assert.equal(p.total_attempts, 2) }) + + test('updateLevel changes the level', () => { + db.updateLevel(profileId, 5) + const p = db.getOrCreateProgress(profileId) + assert.equal(p.level, 5) + }) + + test('updateLevel works even when no progress row exists yet', () => { + const newProfile = db.createProfile('updatelevel@example.com', 'UpdateLevel') + db.updateLevel(newProfile.id, 3) + const p = db.getOrCreateProgress(newProfile.id) + assert.equal(p.level, 3) + }) }) describe('mnemonics', () => { @@ -140,3 +176,34 @@ describe('letter stats', () => { assert.equal(stats.S.attempts, 2) }) }) + +describe('admin', () => { + before(() => { + const p1 = db.createProfile('admin1@example.com', 'Admin1') + const p2 = db.createProfile('admin2@example.com', 'Admin2') + db.recordAnswer(p1.id, 'A', true) + db.recordAnswer(p2.id, 'B', false) + db.recordAnswer(p2.id, 'B', false) + }) + + test('getAdminUsers returns users list', () => { + const users = db.getAdminUsers() + assert.ok(Array.isArray(users)) + assert.ok(users.length >= 2) + // Each user should have email and id + const emails = users.map(u => u.email) + assert.ok(emails.includes('admin1@example.com')) + assert.ok(emails.includes('admin2@example.com')) + }) + + test('getAdminStats returns totalUsers, avgScore, mostMissed', () => { + const stats = db.getAdminStats() + assert.ok(typeof stats.totalUsers === 'number') + assert.ok(stats.totalUsers >= 2) + assert.ok(typeof stats.avgScore === 'number') + assert.ok(Array.isArray(stats.mostMissed)) + // B was missed twice — should appear in mostMissed + const missedLetters = stats.mostMissed.map(m => m.letter) + assert.ok(missedLetters.includes('B')) + }) +})