Initial commit: runnable Ask a Plumber First server.

This commit is contained in:
2026-08-21 23:30:15 -07:00
parent 7bc79af954
commit d167b9216a
38 changed files with 4174 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
package store
import (
"context"
"errors"
)
// ErrLastAdmin is returned when demoting the only remaining admin.
var ErrLastAdmin = errors.New("cannot demote the last admin")
// DB is the persistence API used by the web layer.
// Named DB to avoid colliding with scs.Store.
type DB interface {
CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error)
UserByID(ctx context.Context, id string) (*User, error)
UserByUsername(ctx context.Context, username string) (*User, error)
CountAdmins(ctx context.Context) (int, error)
ListUsers(ctx context.Context) ([]User, error)
SetRole(ctx context.Context, userID, role string) error
CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
Vote(ctx context.Context, userID, questionID string, value int) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, questionID, authorID, body string) error
HideQuestion(ctx context.Context, id string) error
UpdateProfile(ctx context.Context, userID, state, avatarURL string) error
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
}
// Compile-time check: *Store implements DB.
var _ DB = (*Store)(nil)
+28
View File
@@ -0,0 +1,28 @@
package store
import (
"database/sql"
"fmt"
"strings"
)
func migrateUserProfileColumns(db *sql.DB, dialect string) error {
cols := []string{"avatar_url", "state"}
for _, col := range cols {
var stmt string
switch dialect {
case dialectPostgres:
stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col)
default:
stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN %s TEXT NOT NULL DEFAULT ''`, col)
}
if _, err := db.Exec(stmt); err != nil {
// SQLite errors when the column already exists.
if dialect == dialectSQLite && strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
continue
}
return fmt.Errorf("add column %s: %w", col, err)
}
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package store
import (
"database/sql"
"fmt"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
const (
dialectSQLite = "sqlite"
dialectPostgres = "postgres"
)
func rebind(query string) string {
n := 0
var b strings.Builder
for i := 0; i < len(query); i++ {
if query[i] == '?' {
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
continue
}
b.WriteByte(query[i])
}
return b.String()
}
func (s *Store) q(query string) string {
if s.dialect == dialectPostgres {
return rebind(query)
}
return query
}
func applySchema(db *sql.DB, schema string) error {
for _, stmt := range strings.Split(schema, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
upper := strings.ToUpper(stmt)
if strings.HasPrefix(upper, "PRAGMA") {
continue
}
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("%w: %s", err, stmt)
}
}
return nil
}
func postgresDSN(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("DATABASE_URL: %w", err)
}
switch u.Scheme {
case "postgres", "postgresql":
default:
return "", fmt.Errorf("DATABASE_URL must be a postgres URL")
}
q := u.Query()
if strings.EqualFold(q.Get("sslrootcert"), "system") {
q.Del("sslrootcert")
}
q.Del("sslnegotiation")
if q.Get("sslmode") == "" {
q.Set("sslmode", "verify-full")
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func OpenPostgres(databaseURL, schema string) (*Store, error) {
return openPostgres(databaseURL, schema, 5*time.Minute)
}
// OpenPostgresWithoutSessionCleanup opens Postgres without a session cleanup goroutine (for tests).
func OpenPostgresWithoutSessionCleanup(databaseURL, schema string) (*Store, error) {
return openPostgres(databaseURL, schema, 0)
}
func openPostgres(databaseURL, schema string, sessionCleanup time.Duration) (*Store, error) {
dsn, err := postgresDSN(databaseURL)
if err != nil {
return nil, err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
if err := applySchema(db, schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
if err := applySessionsSchema(db, dialectPostgres); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply sessions schema: %w", err)
}
if err := migrateUserProfileColumns(db, dialectPostgres); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate profile columns: %w", err)
}
st := &Store{db: db, dialect: dialectPostgres}
st.initSessionStore(sessionCleanup)
return st, nil
}
// Connect uses PlanetScale Postgres when DATABASE_URL is set, otherwise SQLite.
func Connect(databaseURL, sqlitePath, schema string) (*Store, error) {
if strings.TrimSpace(databaseURL) != "" {
return OpenPostgres(databaseURL, schema)
}
if sqlitePath == "" {
sqlitePath = "data.db"
}
return Open(sqlitePath, schema)
}
+41
View File
@@ -0,0 +1,41 @@
package store
import "testing"
func TestRebindPostgresPlaceholders(t *testing.T) {
got := rebind(`SELECT a FROM t WHERE x = ? AND y = ?`)
want := `SELECT a FROM t WHERE x = $1 AND y = $2`
if got != want {
t.Fatalf("got %q", got)
}
}
func TestNormalizeUsername(t *testing.T) {
if got := NormalizeUsername(" Alice_1 "); got != "alice_1" {
t.Fatalf("got %q", got)
}
}
func TestPostgresDSNDefaultsSSLMode(t *testing.T) {
in := "postgresql://user:pass@db.example.com:5432/postgres"
out, err := postgresDSN(in)
if err != nil {
t.Fatal(err)
}
if !containsAny(out, "sslmode=verify-full") {
t.Fatalf("missing default sslmode: %s", out)
}
}
func containsAny(s string, parts ...string) bool {
for _, p := range parts {
if len(p) > 0 && (len(s) >= len(p)) {
for i := 0; i+len(p) <= len(s); i++ {
if s[i:i+len(p)] == p {
return true
}
}
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package store
import (
"database/sql"
"log"
"time"
"github.com/alexedwards/scs/postgresstore"
"github.com/alexedwards/scs/v2"
)
const sessionsSchemaSQLite = `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
`
const sessionsSchemaPostgres = `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
expiry TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry);
`
func applySessionsSchema(db *sql.DB, dialect string) error {
schema := sessionsSchemaSQLite
if dialect == dialectPostgres {
schema = sessionsSchemaPostgres
}
return applySchema(db, schema)
}
type sessionStopper interface {
StopCleanup()
}
// SessionStore returns the scs store backed by this database.
func (s *Store) SessionStore() scs.Store {
return s.sessionStore
}
func (s *Store) initSessionStore(cleanupInterval time.Duration) {
switch s.dialect {
case dialectPostgres:
ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval)
s.sessionStore = ps
s.sessionStopper = ps
default:
ss := newSQLiteSessionStore(s.db, cleanupInterval)
s.sessionStore = ss
s.sessionStopper = ss
}
}
// sqliteSessionStore is a modernc-safe scs.Store (uses ? placeholders).
type sqliteSessionStore struct {
db *sql.DB
stopCleanup chan bool
}
func newSQLiteSessionStore(db *sql.DB, cleanupInterval time.Duration) *sqliteSessionStore {
s := &sqliteSessionStore{db: db}
if cleanupInterval > 0 {
s.stopCleanup = make(chan bool)
go s.startCleanup(cleanupInterval)
}
return s
}
func (s *sqliteSessionStore) Find(token string) ([]byte, bool, error) {
var b []byte
err := s.db.QueryRow(
`SELECT data FROM sessions WHERE token = ? AND julianday('now') < expiry`,
token,
).Scan(&b)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return b, true, nil
}
func (s *sqliteSessionStore) Commit(token string, b []byte, expiry time.Time) error {
_, err := s.db.Exec(
`REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, julianday(?))`,
token,
b,
expiry.UTC().Format("2006-01-02T15:04:05.999"),
)
return err
}
func (s *sqliteSessionStore) Delete(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *sqliteSessionStore) All() (map[string][]byte, error) {
rows, err := s.db.Query(`SELECT token, data FROM sessions WHERE julianday('now') < expiry`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]byte)
for rows.Next() {
var token string
var data []byte
if err := rows.Scan(&token, &data); err != nil {
return nil, err
}
out[token] = data
}
return out, rows.Err()
}
func (s *sqliteSessionStore) startCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
if err := s.deleteExpired(); err != nil {
log.Println(err)
}
case <-s.stopCleanup:
ticker.Stop()
return
}
}
}
func (s *sqliteSessionStore) StopCleanup() {
if s.stopCleanup != nil {
s.stopCleanup <- true
}
}
func (s *sqliteSessionStore) deleteExpired() error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE expiry < julianday('now')`)
return err
}
// Ensure interface compliance.
var (
_ scs.Store = (*sqliteSessionStore)(nil)
_ scs.IterableStore = (*sqliteSessionStore)(nil)
_ sessionStopper = (*sqliteSessionStore)(nil)
)
+406
View File
@@ -0,0 +1,406 @@
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(&current)
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(&current)
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()
}