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 <noreply@anthropic.com>
This commit is contained in:
+253
@@ -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 }
|
||||
Reference in New Issue
Block a user