Phase 1: database schema, migrations, repository, user seeding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jānis Kacēns
2026-05-11 11:38:31 +03:00
parent 34eb47b595
commit 0bc9160d97
8 changed files with 668 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"database/sql"
_ "embed"
"fmt"
_ "modernc.org/sqlite"
"golang.org/x/crypto/bcrypt"
"qbank/internal/config"
)
//go:embed schema.sql
var schema string
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
for _, stmt := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
} {
if _, err := db.Exec(stmt); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", stmt, err)
}
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return db, nil
}
func Seed(db *sql.DB, users []config.AdminUser) error {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
for _, u := range users {
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password for %q: %w", u.Name, err)
}
if _, err := db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", u.Name, string(hash)); err != nil {
return fmt.Errorf("insert user %q: %w", u.Name, err)
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package db_test
import (
"os"
"path/filepath"
"testing"
"qbank/internal/config"
"qbank/internal/db"
)
func TestOpenAndSeed(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer database.Close()
// All tables must exist.
tables := []string{"users", "questions", "answers", "tests", "test_answers", "user_question_stats"}
for _, table := range tables {
var name string
err := database.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
).Scan(&name)
if err != nil {
t.Errorf("table %q missing: %v", table, err)
}
}
// Seed two users.
users := []config.AdminUser{
{Name: "alice", Password: "pass1"},
{Name: "bob", Password: "pass2"},
}
if err := db.Seed(database, users); err != nil {
t.Fatalf("Seed: %v", err)
}
var count int
database.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count != 2 {
t.Errorf("want 2 users, got %d", count)
}
// Second Seed call must be a no-op (users table non-empty).
if err := db.Seed(database, users); err != nil {
t.Fatalf("second Seed: %v", err)
}
database.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count != 2 {
t.Errorf("after second seed: want 2 users, got %d", count)
}
// GetUserByName must find alice.
repo := db.New(database)
u, err := repo.GetUserByName("alice")
if err != nil {
t.Fatalf("GetUserByName: %v", err)
}
if u.Name != "alice" {
t.Errorf("want alice, got %q", u.Name)
}
if u.PasswordHash == "" {
t.Error("password hash is empty")
}
_ = os.Remove(dbPath)
}
+375
View File
@@ -0,0 +1,375 @@
package db
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"qbank/internal/models"
)
const timeLayout = "2006-01-02 15:04:05"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeLayout, s)
return t
}
func parseNullTime(s sql.NullString) sql.NullTime {
if !s.Valid {
return sql.NullTime{}
}
t, err := time.Parse(timeLayout, s.String)
if err != nil {
return sql.NullTime{}
}
return sql.NullTime{Time: t, Valid: true}
}
// QuestionID computes the canonical ID for a question from its text.
func QuestionID(text string) string {
h := sha256.Sum256([]byte(text))
return fmt.Sprintf("%x", h[:8])
}
type SortOrder int
const (
SortAlpha SortOrder = iota // alphabetical by question text
SortWeakest // lowest accuracy first (requires UserID)
SortMostSeen // most-seen first (requires UserID)
)
type ListFilter struct {
Source string
Search string
Sort SortOrder
UserID int64
}
type Repo struct {
db *sql.DB
}
func New(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) CreateUser(name, passwordHash string) (int64, error) {
res, err := r.db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", name, passwordHash)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (r *Repo) GetUserByName(name string) (*models.User, error) {
u := &models.User{}
var createdAt string
err := r.db.QueryRow(
"SELECT id, name, password_hash, created_at FROM users WHERE name = ?", name,
).Scan(&u.ID, &u.Name, &u.PasswordHash, &createdAt)
if err != nil {
return nil, err
}
u.CreatedAt = parseTime(createdAt)
return u, nil
}
// InsertQuestion inserts q and its answers in a transaction. Duplicate questions
// (same text hash) are silently ignored; their answers are not re-inserted.
func (r *Repo) InsertQuestion(q *models.Question, answers []*models.Answer) error {
q.ID = QuestionID(q.Text)
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT OR IGNORE INTO questions (id, text, source) VALUES (?, ?, ?)",
q.ID, q.Text, q.Source,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return tx.Commit() // already exists
}
for i, a := range answers {
a.QuestionID = q.ID
a.Position = i
res, err := tx.Exec(
"INSERT INTO answers (question_id, text, is_correct, position) VALUES (?, ?, ?, ?)",
a.QuestionID, a.Text, a.IsCorrect, a.Position,
)
if err != nil {
return err
}
a.ID, _ = res.LastInsertId()
}
return tx.Commit()
}
func (r *Repo) GetQuestion(id string) (*models.Question, []*models.Answer, error) {
q := &models.Question{}
var createdAt string
err := r.db.QueryRow(
"SELECT id, text, source, created_at FROM questions WHERE id = ?", id,
).Scan(&q.ID, &q.Text, &q.Source, &createdAt)
if err != nil {
return nil, nil, err
}
q.CreatedAt = parseTime(createdAt)
rows, err := r.db.Query(
"SELECT id, question_id, text, is_correct, position FROM answers WHERE question_id = ? ORDER BY position",
id,
)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var answers []*models.Answer
for rows.Next() {
a := &models.Answer{}
if err := rows.Scan(&a.ID, &a.QuestionID, &a.Text, &a.IsCorrect, &a.Position); err != nil {
return nil, nil, err
}
answers = append(answers, a)
}
return q, answers, rows.Err()
}
func (r *Repo) ListQuestions(f ListFilter) ([]*models.Question, error) {
var where []string
var args []any
if f.Source != "" {
where = append(where, "source = ?")
args = append(args, f.Source)
}
if f.Search != "" {
where = append(where, "text LIKE ?")
args = append(args, "%"+f.Search+"%")
}
query := "SELECT id, text, source, created_at FROM questions"
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY text COLLATE NOCASE ASC"
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var qs []*models.Question
for rows.Next() {
q := &models.Question{}
var createdAt string
if err := rows.Scan(&q.ID, &q.Text, &q.Source, &createdAt); err != nil {
return nil, err
}
q.CreatedAt = parseTime(createdAt)
qs = append(qs, q)
}
return qs, rows.Err()
}
func (r *Repo) ListSources() ([]string, error) {
rows, err := r.db.Query(
"SELECT DISTINCT source FROM questions WHERE source != '' ORDER BY source",
)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, rows.Err()
}
func (r *Repo) CountQuestions() (int, error) {
var n int
return n, r.db.QueryRow("SELECT COUNT(*) FROM questions").Scan(&n)
}
func (r *Repo) CountAnswers() (int, error) {
var n int
return n, r.db.QueryRow("SELECT COUNT(*) FROM answers").Scan(&n)
}
func (r *Repo) CreateTest(userID int64, questionIDs []string) (int64, error) {
ids, err := json.Marshal(questionIDs)
if err != nil {
return 0, err
}
res, err := r.db.Exec(
"INSERT INTO tests (user_id, n_questions, question_ids) VALUES (?, ?, ?)",
userID, len(questionIDs), string(ids),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (r *Repo) RecordAnswer(testID int64, questionID string, selectedAnswerID *int64, isCorrect bool) error {
_, err := r.db.Exec(`
INSERT INTO test_answers (test_id, question_id, selected_answer_id, is_correct, answered_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (test_id, question_id) DO UPDATE SET
selected_answer_id = excluded.selected_answer_id,
is_correct = excluded.is_correct,
answered_at = excluded.answered_at`,
testID, questionID, selectedAnswerID, isCorrect,
)
return err
}
func (r *Repo) FinishTest(id int64) error {
_, err := r.db.Exec("UPDATE tests SET completed_at = CURRENT_TIMESTAMP WHERE id = ?", id)
return err
}
func (r *Repo) GetTest(id int64) (*models.Test, error) {
t := &models.Test{}
var createdAt string
var completedAt sql.NullString
var ids string
err := r.db.QueryRow(
"SELECT id, user_id, created_at, completed_at, n_questions, question_ids FROM tests WHERE id = ?", id,
).Scan(&t.ID, &t.UserID, &createdAt, &completedAt, &t.NQuestions, &ids)
if err != nil {
return nil, err
}
t.CreatedAt = parseTime(createdAt)
t.CompletedAt = parseNullTime(completedAt)
if err := json.Unmarshal([]byte(ids), &t.QuestionIDs); err != nil {
return nil, err
}
return t, nil
}
func (r *Repo) ListTestsForUser(userID int64) ([]*models.Test, error) {
rows, err := r.db.Query(`
SELECT id, user_id, created_at, completed_at, n_questions, question_ids
FROM tests WHERE user_id = ? ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var tests []*models.Test
for rows.Next() {
t := &models.Test{}
var createdAt string
var completedAt sql.NullString
var ids string
if err := rows.Scan(&t.ID, &t.UserID, &createdAt, &completedAt, &t.NQuestions, &ids); err != nil {
return nil, err
}
t.CreatedAt = parseTime(createdAt)
t.CompletedAt = parseNullTime(completedAt)
if err := json.Unmarshal([]byte(ids), &t.QuestionIDs); err != nil {
return nil, err
}
tests = append(tests, t)
}
return tests, rows.Err()
}
func (r *Repo) GetTestAnswers(testID int64) ([]*models.TestAnswer, error) {
rows, err := r.db.Query(`
SELECT test_id, question_id, selected_answer_id, is_correct, answered_at
FROM test_answers WHERE test_id = ?`, testID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var answers []*models.TestAnswer
for rows.Next() {
ta := &models.TestAnswer{}
var answeredAt sql.NullString
if err := rows.Scan(&ta.TestID, &ta.QuestionID, &ta.SelectedAnswerID, &ta.IsCorrect, &answeredAt); err != nil {
return nil, err
}
ta.AnsweredAt = parseNullTime(answeredAt)
answers = append(answers, ta)
}
return answers, rows.Err()
}
func (r *Repo) UpsertStat(userID int64, questionID string, gotItRight bool) error {
correct := 0
if gotItRight {
correct = 1
}
_, err := r.db.Exec(`
INSERT INTO user_question_stats (user_id, question_id, times_seen, times_correct, last_seen_at)
VALUES (?, ?, 1, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, question_id) DO UPDATE SET
times_seen = times_seen + 1,
times_correct = times_correct + excluded.times_correct,
last_seen_at = CURRENT_TIMESTAMP`,
userID, questionID, correct,
)
return err
}
func (r *Repo) GetStatsForUser(userID int64, questionIDs []string) (map[string]*models.UserQuestionStat, error) {
result := make(map[string]*models.UserQuestionStat, len(questionIDs))
if len(questionIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(questionIDs))
args := make([]any, 0, len(questionIDs)+1)
args = append(args, userID)
for i, id := range questionIDs {
placeholders[i] = "?"
args = append(args, id)
}
rows, err := r.db.Query(fmt.Sprintf(`
SELECT user_id, question_id, times_seen, times_correct, last_seen_at
FROM user_question_stats
WHERE user_id = ? AND question_id IN (%s)`,
strings.Join(placeholders, ",")),
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
s := &models.UserQuestionStat{}
var lastSeen sql.NullString
if err := rows.Scan(&s.UserID, &s.QuestionID, &s.TimesSeen, &s.TimesCorrect, &lastSeen); err != nil {
return nil, err
}
s.LastSeenAt = parseNullTime(lastSeen)
result[s.QuestionID] = s
}
return result, rows.Err()
}
+52
View File
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
source TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL,
position INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
n_questions INTEGER NOT NULL,
question_ids TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS test_answers (
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
question_id TEXT NOT NULL REFERENCES questions(id),
selected_answer_id INTEGER REFERENCES answers(id),
is_correct BOOLEAN,
answered_at DATETIME,
PRIMARY KEY (test_id, question_id)
);
CREATE TABLE IF NOT EXISTS user_question_stats (
user_id INTEGER NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
times_seen INTEGER NOT NULL DEFAULT 0,
times_correct INTEGER NOT NULL DEFAULT 0,
last_seen_at DATETIME,
PRIMARY KEY (user_id, question_id)
);
CREATE INDEX IF NOT EXISTS idx_test_answers_test ON test_answers(test_id);
CREATE INDEX IF NOT EXISTS idx_answers_question ON answers(question_id);
CREATE INDEX IF NOT EXISTS idx_stats_user ON user_question_stats(user_id);
+53
View File
@@ -0,0 +1,53 @@
package models
import (
"database/sql"
"time"
)
type User struct {
ID int64
Name string
PasswordHash string
CreatedAt time.Time
}
type Question struct {
ID string // sha256(text)[:8 bytes] hex = 16 chars
Text string
Source string
CreatedAt time.Time
}
type Answer struct {
ID int64
QuestionID string
Text string
IsCorrect bool
Position int
}
type Test struct {
ID int64
UserID int64
CreatedAt time.Time
CompletedAt sql.NullTime
NQuestions int
QuestionIDs []string // unmarshaled from JSON
}
type TestAnswer struct {
TestID int64
QuestionID string
SelectedAnswerID sql.NullInt64
IsCorrect sql.NullBool
AnsweredAt sql.NullTime
}
type UserQuestionStat struct {
UserID int64
QuestionID string
TimesSeen int
TimesCorrect int
LastSeenAt sql.NullTime
}