Phase 1: database schema, migrations, repository, user seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"qbank/internal/config"
|
"qbank/internal/config"
|
||||||
|
"qbank/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -21,6 +23,25 @@ func main() {
|
|||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
slog.SetDefault(logger)
|
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 := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(requestLogger(logger))
|
r.Use(requestLogger(logger))
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
module qbank
|
module qbank
|
||||||
|
|
||||||
go 1.24.6
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.2.5
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user