From 0bc9160d97009fdcd476b7e7fcbdc0cb062a1e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20Kac=C4=93ns?= Date: Mon, 11 May 2026 11:38:31 +0300 Subject: [PATCH] Phase 1: database schema, migrations, repository, user seeding Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/main.go | 21 +++ go.mod | 16 +- go.sum | 23 +++ internal/db/db.go | 57 ++++++ internal/db/db_test.go | 72 ++++++++ internal/db/repo.go | 375 ++++++++++++++++++++++++++++++++++++++ internal/db/schema.sql | 52 ++++++ internal/models/models.go | 53 ++++++ 8 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/repo.go create mode 100644 internal/db/schema.sql create mode 100644 internal/models/models.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 3add94e..1de2d4f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -13,6 +14,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "qbank/internal/config" + "qbank/internal/db" ) func main() { @@ -21,6 +23,25 @@ func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) + if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { + slog.Error("create data dir", "err", err) + os.Exit(1) + } + + database, err := db.Open(filepath.Join(cfg.DataDir, "qbank.db")) + if err != nil { + slog.Error("open database", "err", err) + os.Exit(1) + } + defer database.Close() + + if err := db.Seed(database, cfg.AdminUsers); err != nil { + slog.Error("seed users", "err", err) + os.Exit(1) + } + + _ = db.New(database) // repo will be wired into handlers in later phases + r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(requestLogger(logger)) diff --git a/go.mod b/go.mod index 12a145c..8af926b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,19 @@ module qbank -go 1.24.6 +go 1.25.0 require github.com/go-chi/chi/v5 v5.2.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum index a4ac04e..3364fe8 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,25 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..6553f50 --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..5686ebb --- /dev/null +++ b/internal/db/db_test.go @@ -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) +} diff --git a/internal/db/repo.go b/internal/db/repo.go new file mode 100644 index 0000000..1b7d307 --- /dev/null +++ b/internal/db/repo.go @@ -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() +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql new file mode 100644 index 0000000..59b87cc --- /dev/null +++ b/internal/db/schema.sql @@ -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); diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..97b8a85 --- /dev/null +++ b/internal/models/models.go @@ -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 +}