From 9b31119c7af9bca5356440d919fef49a72710e75 Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 00:34:59 +0000 Subject: [PATCH] feat: database layer with better-sqlite3, full schema and query functions Implements Task 4 (TDD): wrote failing tests first, then the full SQLite database layer covering profiles, magic tokens, sessions, user progress, mnemonics, and letter stats. All 14 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- data/.gitkeep | 0 server/db.js | 253 ++++++++++++++++++++++++++++++++++++++++ server/tests/db.test.js | 142 ++++++++++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 data/.gitkeep create mode 100644 server/db.js create mode 100644 server/tests/db.test.js diff --git a/.gitignore b/.gitignore index 58a22d0..af5fc62 100644 --- a/.gitignore +++ b/.gitignore @@ -180,7 +180,8 @@ dist *.db *.sqlite *.sqlite3 -data/ +data/* +!data/.gitkeep # Build artifacts build/ diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..cdf65fb --- /dev/null +++ b/server/db.js @@ -0,0 +1,253 @@ +'use strict' +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) + 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, + 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, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_progress ( + profile_id INTEGER PRIMARY KEY, + 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, + 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, + 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) + ); + `) + + db = instance + + return { + _db: instance, + createProfile, + findProfileByEmail, + findProfileById, + updateProfileSeen, + createMagicToken, + useMagicToken, + createSession, + getSession, + deleteSession, + getOrCreateProgress, + updateLevel, + recordAnswer, + getMnemonics, + saveMnemonic, + getLetterStats, + getAdminUsers, + getAdminStats, + } +} + +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 new file mode 100644 index 0000000..6aab2e6 --- /dev/null +++ b/server/tests/db.test.js @@ -0,0 +1,142 @@ +'use strict' +const { test, describe, before, after } = require('node:test') +const assert = require('node:assert/strict') +const { initDb } = require('../db.js') + +let db + +before(() => { + db = initDb(':memory:') +}) + +after(() => { + db._db.close() +}) + +describe('profiles', () => { + test('createProfile creates a profile', () => { + const p = db.createProfile('test@example.com', 'Tester') + assert.equal(p.email, 'test@example.com') + assert.equal(p.display_name, 'Tester') + assert.ok(p.id) + }) + + test('findProfileByEmail returns the profile', () => { + const p = db.findProfileByEmail('test@example.com') + assert.equal(p.email, 'test@example.com') + }) + + test('findProfileByEmail returns null for unknown email', () => { + assert.equal(db.findProfileByEmail('nobody@example.com'), null) + }) +}) + +describe('magic tokens', () => { + let profileId + + before(() => { + profileId = db.createProfile('magic@example.com', 'Magic').id + }) + + test('createMagicToken and useMagicToken returns profileId', () => { + const expires = Date.now() + 86400000 + db.createMagicToken('tok123', profileId, expires) + const result = db.useMagicToken('tok123') + assert.equal(result, profileId) + }) + + test('useMagicToken returns null for used token', () => { + assert.equal(db.useMagicToken('tok123'), null) + }) + + test('useMagicToken returns null for expired token', () => { + db.createMagicToken('expiredtok', profileId, Date.now() - 1000) + assert.equal(db.useMagicToken('expiredtok'), null) + }) +}) + +describe('sessions', () => { + let profileId + + before(() => { + profileId = db.createProfile('session@example.com', 'Session').id + }) + + test('createSession and getSession returns profile_id', () => { + db.createSession('sess1', profileId) + const s = db.getSession('sess1') + assert.equal(s.profile_id, profileId) + }) + + test('getSession returns null for unknown token', () => { + assert.equal(db.getSession('unknown'), null) + }) + + test('deleteSession removes session', () => { + db.deleteSession('sess1') + assert.equal(db.getSession('sess1'), null) + }) +}) + +describe('progress', () => { + let profileId + + before(() => { + profileId = db.createProfile('progress@example.com', 'Progress').id + }) + + test('getOrCreateProgress returns default progress', () => { + const p = db.getOrCreateProgress(profileId) + assert.equal(p.level, 1) + assert.equal(p.score, 0) + assert.equal(p.streak, 0) + }) + + test('recordAnswer increments score and streak on correct', () => { + db.recordAnswer(profileId, 'A', true) + const p = db.getOrCreateProgress(profileId) + assert.ok(p.score > 0) + assert.equal(p.streak, 1) + assert.equal(p.total_correct, 1) + assert.equal(p.total_attempts, 1) + }) + + test('recordAnswer resets streak on incorrect', () => { + db.recordAnswer(profileId, 'A', false) + const p = db.getOrCreateProgress(profileId) + assert.equal(p.streak, 0) + assert.equal(p.total_attempts, 2) + }) +}) + +describe('mnemonics', () => { + let profileId + + before(() => { + profileId = db.createProfile('mnemonic@example.com', 'Mnemonic').id + }) + + test('saveMnemonic and getMnemonics returns map', () => { + db.saveMnemonic(profileId, 'A', 'go WALKING') + db.saveMnemonic(profileId, 'E', 'go') + const m = db.getMnemonics(profileId) + assert.equal(m.A, 'go WALKING') + assert.equal(m.E, 'go') + }) +}) + +describe('letter stats', () => { + let profileId + + before(() => { + profileId = db.createProfile('stats@example.com', 'Stats').id + }) + + test('recordAnswer tracks letter stats', () => { + db.recordAnswer(profileId, 'S', true) + db.recordAnswer(profileId, 'S', false) + const stats = db.getLetterStats(profileId) + assert.equal(stats.S.correct, 1) + assert.equal(stats.S.attempts, 2) + }) +})