package store import ( "context" "database/sql" "fmt" "strings" "time" "github.com/alexedwards/scs/v2" "github.com/google/uuid" _ "modernc.org/sqlite" "plumber/internal/pacific" ) type Store struct { db *sql.DB dialect string sessionStore scs.Store sessionStopper sessionStopper } type User struct { ID string Username string Name string Role string AvatarURL string State string CreatedAt string PasswordHash string } func (u *User) Admin() bool { return u != nil && u.Role == "admin" } type RankedQuestion struct { ID string AuthorID string AuthorName string Title string Body string City string HuntDate string Hidden bool CreatedAt string Score int Answered bool UserVote int } type Answer struct { QuestionID string AuthorID string AuthorName string Body string CreatedAt string UpdatedAt string } func Open(path, schema string) (*Store, error) { return openSQLite(path, schema, 5*time.Minute) } // OpenWithoutSessionCleanup opens SQLite without a session cleanup goroutine (for tests). func OpenWithoutSessionCleanup(path, schema string) (*Store, error) { return openSQLite(path, schema, 0) } func openSQLite(path, schema string, sessionCleanup time.Duration) (*Store, error) { dsn := path if !strings.Contains(dsn, "?") { dsn += "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { _ = db.Close() return nil, fmt.Errorf("apply schema: %w", err) } if err := applySessionsSchema(db, dialectSQLite); err != nil { _ = db.Close() return nil, fmt.Errorf("apply sessions schema: %w", err) } if err := migrateUserProfileColumns(db, dialectSQLite); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate profile columns: %w", err) } st := &Store{db: db, dialect: dialectSQLite} st.initSessionStore(sessionCleanup) return st, nil } func (s *Store) Close() error { if s.sessionStopper != nil { s.sessionStopper.StopCleanup() s.sessionStopper = nil } return s.db.Close() } func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error) { username = NormalizeUsername(username) role := "user" if asAdmin { role = "admin" } u := &User{ ID: uuid.NewString(), Username: username, Name: username, Role: role, PasswordHash: passwordHash, CreatedAt: time.Now().UTC().Format(time.RFC3339), } _, err := s.db.ExecContext(ctx, s.q(`INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES (?, ?, ?, ?, ?, '', '', ?)`), u.ID, u.Username, u.Name, u.PasswordHash, u.Role, u.CreatedAt) if err != nil { return nil, err } return u, nil } func (s *Store) CountAdmins(ctx context.Context) (int, error) { var n int err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), "admin").Scan(&n) return n, err } func (s *Store) ListUsers(ctx context.Context) ([]User, error) { rows, err := s.db.QueryContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`)) if err != nil { return nil, err } defer rows.Close() var out []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } func (s *Store) SetRole(ctx context.Context, userID, role string) error { if role != "user" && role != "admin" { return fmt.Errorf("invalid role") } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var current string err = tx.QueryRowContext(ctx, s.q(`SELECT role FROM users WHERE id = ?`), userID).Scan(¤t) if err != nil { return err } if current == "admin" && role == "user" { var n int if err := tx.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), "admin").Scan(&n); err != nil { return err } if n <= 1 { return ErrLastAdmin } } res, err := tx.ExecContext(ctx, s.q(`UPDATE users SET role = ? WHERE id = ?`), role, userID) if err != nil { return err } aff, err := res.RowsAffected() if err != nil { return err } if aff == 0 { return sql.ErrNoRows } return tx.Commit() } func (s *Store) UserByID(ctx context.Context, id string) (*User, error) { return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = ?`), id), false) } func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) { return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = ?`), NormalizeUsername(username)), true) } func scanUser(row *sql.Row, withSecrets bool) (*User, error) { var u User var err error if withSecrets { err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash) } else { err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt) } if err != nil { return nil, err } return &u, nil } func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func (s *Store) CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error) { q := &RankedQuestion{ ID: uuid.NewString(), AuthorID: authorID, Title: strings.TrimSpace(title), Body: strings.TrimSpace(body), City: strings.TrimSpace(city), HuntDate: pacific.Today(), CreatedAt: time.Now().UTC().Format(time.RFC3339), } _, err := s.db.ExecContext(ctx, s.q(`INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`), q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt) if err != nil { return nil, err } return q, nil } func (s *Store) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) { rows, err := s.db.QueryContext(ctx, s.q(` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE(SUM(v.value), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) AS user_vote FROM questions q JOIN users u ON u.id = q.author_id LEFT JOIN votes v ON v.question_id = q.id LEFT JOIN answers a ON a.question_id = q.id WHERE q.hunt_date = ? AND q.hidden = 0 GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id ORDER BY score DESC, q.created_at ASC`), viewerID, huntDate) if err != nil { return nil, err } defer rows.Close() var out []RankedQuestion for rows.Next() { q, err := scanRanked(rows) if err != nil { return nil, err } out = append(out, q) } return out, rows.Err() } func (s *Store) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) { row := s.db.QueryRowContext(ctx, s.q(` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) AS user_vote FROM questions q JOIN users u ON u.id = q.author_id LEFT JOIN answers a ON a.question_id = q.id WHERE q.id = ?`), viewerID, id) q, err := scanRankedRow(row) if err != nil { return nil, err } return &q, nil } type scanned interface { Scan(dest ...any) error } func scanRanked(rows scanned) (RankedQuestion, error) { var q RankedQuestion var hidden, answered int err := rows.Scan(&q.ID, &q.AuthorID, &q.AuthorName, &q.Title, &q.Body, &q.City, &q.HuntDate, &hidden, &q.CreatedAt, &q.Score, &answered, &q.UserVote) q.Hidden = hidden != 0 q.Answered = answered != 0 return q, err } func scanRankedRow(row *sql.Row) (RankedQuestion, error) { return scanRanked(row) } func (s *Store) Vote(ctx context.Context, userID, questionID string, value int) error { if value != 1 && value != -1 { return fmt.Errorf("invalid vote") } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var current sql.NullInt64 err = tx.QueryRowContext(ctx, s.q(`SELECT value FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID).Scan(¤t) if err != nil && err != sql.ErrNoRows { return err } if err == nil && current.Valid && int(current.Int64) == value { _, err = tx.ExecContext(ctx, s.q(`DELETE FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID) } else { _, err = tx.ExecContext(ctx, s.q(`INSERT INTO votes (user_id, question_id, value) VALUES (?, ?, ?) ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`), userID, questionID, value) } if err != nil { return err } return tx.Commit() } func (s *Store) GetAnswer(ctx context.Context, questionID string) (*Answer, error) { var a Answer err := s.db.QueryRowContext(ctx, s.q(` SELECT a.question_id, a.author_id, u.name, a.body, a.created_at, a.updated_at FROM answers a JOIN users u ON u.id = a.author_id WHERE a.question_id = ?`), questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err } return &a, nil } func (s *Store) UpsertAnswer(ctx context.Context, questionID, authorID, body string) error { body = strings.TrimSpace(body) now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.ExecContext(ctx, s.q(` INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (question_id) DO UPDATE SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at`), questionID, authorID, body, now, now) return err } func (s *Store) HideQuestion(ctx context.Context, id string) error { _, err := s.db.ExecContext(ctx, s.q(`UPDATE questions SET hidden = 1 WHERE id = ?`), id) return err } func (s *Store) UpdateProfile(ctx context.Context, userID, state, avatarURL string) error { state = strings.TrimSpace(state) if avatarURL == "" { _, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ? WHERE id = ?`), state, userID) return err } _, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ?, avatar_url = ? WHERE id = ?`), state, avatarURL, userID) return err } func (s *Store) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) { rows, err := s.db.QueryContext(ctx, s.q(` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, 0 AS user_vote FROM questions q JOIN users u ON u.id = q.author_id LEFT JOIN answers a ON a.question_id = q.id WHERE q.author_id = ? AND q.hidden = 0 ORDER BY q.created_at DESC`), authorID) if err != nil { return nil, err } defer rows.Close() return scanRankedList(rows) } func (s *Store) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) { rows, err := s.db.QueryContext(ctx, s.q(` SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, 1 AS answered, 0 AS user_vote FROM answers ans JOIN questions q ON q.id = ans.question_id JOIN users u ON u.id = q.author_id WHERE ans.author_id = ? AND q.hidden = 0 ORDER BY ans.updated_at DESC`), adminID) if err != nil { return nil, err } defer rows.Close() return scanRankedList(rows) } func scanRankedList(rows *sql.Rows) ([]RankedQuestion, error) { var out []RankedQuestion for rows.Next() { q, err := scanRanked(rows) if err != nil { return nil, err } out = append(out, q) } return out, rows.Err() }