Refactor Store into SessionStore; move domain SQL onto User/Question/Answer.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Answer is an admin reply to a question.
|
||||
type Answer struct {
|
||||
QuestionID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Body string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAnswer returns an Answer bound to db.
|
||||
func NewAnswer(db *sql.DB) *Answer {
|
||||
return &Answer{db: db}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates the answer for QuestionID.
|
||||
func (a *Answer) Upsert(ctx context.Context) error {
|
||||
if a == nil || a.db == nil {
|
||||
return fmt.Errorf("answer: no database")
|
||||
}
|
||||
a.Body = strings.TrimSpace(a.Body)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if a.CreatedAt == "" {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
a.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx, `
|
||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (question_id) DO UPDATE SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at`,
|
||||
a.QuestionID, a.AuthorID, a.Body, a.CreatedAt, a.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
|
||||
var a Answer
|
||||
err := db.QueryRowContext(ctx, `
|
||||
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 = $1`, questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.db = db
|
||||
return &a, nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrLastAdmin is returned when demoting the only remaining admin.
|
||||
var ErrLastAdmin = errors.New("cannot demote the last admin")
|
||||
|
||||
// Role is a user privilege level stored in users.role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// NewUser is the input for CreateUser.
|
||||
type NewUser struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
}
|
||||
|
||||
// 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, user NewUser) (*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 string, role Role) 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)
|
||||
@@ -48,34 +48,33 @@ func postgresDSN(raw string) (string, error) {
|
||||
}
|
||||
|
||||
// OpenPostgres connects to Postgres, applies schema/migrations, and starts session cleanup.
|
||||
func OpenPostgres(databaseURL, schema string) (*Store, error) {
|
||||
func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) {
|
||||
dsn, err := postgresDSN(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
||||
return nil, nil, fmt.Errorf("postgres ping: %w", err)
|
||||
}
|
||||
if err := applySchema(db, schema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
return nil, nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
if err := applySessionsSchema(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("apply sessions schema: %w", err)
|
||||
return nil, nil, fmt.Errorf("apply sessions schema: %w", err)
|
||||
}
|
||||
if err := migrateUserProfileColumns(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("migrate profile columns: %w", err)
|
||||
return nil, nil, fmt.Errorf("migrate profile columns: %w", err)
|
||||
}
|
||||
st := &Store{db: db}
|
||||
st.initSessionStore(5 * time.Minute)
|
||||
return st, nil
|
||||
sessions := NewSessionStore(db, 5*time.Minute)
|
||||
return db, sessions, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
)
|
||||
|
||||
// RankedQuestion is a question row with score / vote annotations for lists.
|
||||
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
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewQuestion returns a question bound to db (not yet inserted).
|
||||
func NewQuestion(db *sql.DB) *RankedQuestion {
|
||||
return &RankedQuestion{db: db}
|
||||
}
|
||||
|
||||
// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty.
|
||||
func (q *RankedQuestion) Create(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return fmt.Errorf("question: no database")
|
||||
}
|
||||
q.Title = strings.TrimSpace(q.Title)
|
||||
q.Body = strings.TrimSpace(q.Body)
|
||||
q.City = strings.TrimSpace(q.City)
|
||||
if q.ID == "" {
|
||||
q.ID = uuid.NewString()
|
||||
}
|
||||
if q.HuntDate == "" {
|
||||
q.HuntDate = pacific.Today()
|
||||
}
|
||||
if q.CreatedAt == "" {
|
||||
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := q.db.ExecContext(ctx, `INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`,
|
||||
q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// Hide marks the question hidden.
|
||||
func (q *RankedQuestion) Hide(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return fmt.Errorf("question: no database")
|
||||
}
|
||||
_, err := q.db.ExecContext(ctx, `UPDATE questions SET hidden = 1 WHERE id = $1`, q.ID)
|
||||
if err == nil {
|
||||
q.Hidden = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
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 = $1 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 = $2 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()
|
||||
return scanRankedList(db, rows)
|
||||
}
|
||||
|
||||
func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
|
||||
row := db.QueryRowContext(ctx, `
|
||||
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 = $1 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 = $2`, viewerID, id)
|
||||
q, err := scanRanked(db, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
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 = $1 AND q.hidden = 0
|
||||
ORDER BY q.created_at DESC`, authorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRankedList(db, rows)
|
||||
}
|
||||
|
||||
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
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 = $1 AND q.hidden = 0
|
||||
ORDER BY ans.updated_at DESC`, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRankedList(db, rows)
|
||||
}
|
||||
|
||||
type scanned interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRanked(db *sql.DB, 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
|
||||
q.db = db
|
||||
return q, err
|
||||
}
|
||||
|
||||
func scanRankedList(db *sql.DB, rows *sql.Rows) ([]RankedQuestion, error) {
|
||||
var out []RankedQuestion
|
||||
for rows.Next() {
|
||||
q, err := scanRanked(db, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -26,13 +26,28 @@ type sessionStopper interface {
|
||||
StopCleanup()
|
||||
}
|
||||
|
||||
// SessionStore returns the scs store backed by this database.
|
||||
func (s *Store) SessionStore() scs.Store {
|
||||
return s.sessionStore
|
||||
// SessionStore wraps scs Postgres session persistence and cleanup.
|
||||
type SessionStore struct {
|
||||
store scs.Store
|
||||
stopper sessionStopper
|
||||
}
|
||||
|
||||
func (s *Store) initSessionStore(cleanupInterval time.Duration) {
|
||||
ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval)
|
||||
s.sessionStore = ps
|
||||
s.sessionStopper = ps
|
||||
// NewSessionStore starts a postgresstore with the given cleanup interval.
|
||||
func NewSessionStore(db *sql.DB, cleanupInterval time.Duration) *SessionStore {
|
||||
ps := postgresstore.NewWithCleanupInterval(db, cleanupInterval)
|
||||
return &SessionStore{store: ps, stopper: ps}
|
||||
}
|
||||
|
||||
// Store returns the scs.Store implementation.
|
||||
func (s *SessionStore) Store() scs.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// Close stops background session cleanup.
|
||||
func (s *SessionStore) Close() {
|
||||
if s == nil || s.stopper == nil {
|
||||
return
|
||||
}
|
||||
s.stopper.StopCleanup()
|
||||
s.stopper = nil
|
||||
}
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
sessionStore scs.Store
|
||||
sessionStopper sessionStopper
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role Role
|
||||
AvatarURL string
|
||||
State string
|
||||
CreatedAt string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (u *User) Admin() bool {
|
||||
return u != nil && u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
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 (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, nu NewUser) (*User, error) {
|
||||
if nu.Role != RoleUser && nu.Role != RoleAdmin {
|
||||
return nil, fmt.Errorf("invalid role")
|
||||
}
|
||||
username := NormalizeUsername(nu.Username)
|
||||
u := &User{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Name: username,
|
||||
Role: nu.Role,
|
||||
PasswordHash: nu.PasswordHash,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`,
|
||||
u.ID, u.Username, u.Name, u.PasswordHash, string(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, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `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
|
||||
var role string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = Role(role)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetRole(ctx context.Context, userID string, role Role) error {
|
||||
if role != RoleUser && role != RoleAdmin {
|
||||
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, `SELECT role FROM users WHERE id = $1`, userID).Scan(¤t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if Role(current) == RoleAdmin && role == RoleUser {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(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, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false)
|
||||
}
|
||||
|
||||
func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true)
|
||||
}
|
||||
|
||||
func scanUser(row *sql.Row, withSecrets bool) (*User, error) {
|
||||
var u User
|
||||
var role string
|
||||
var err error
|
||||
if withSecrets {
|
||||
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash)
|
||||
} else {
|
||||
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = Role(role)
|
||||
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, `INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`,
|
||||
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, `
|
||||
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 = $1 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 = $2 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, `
|
||||
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 = $1 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 = $2`, 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, `SELECT value FROM votes WHERE user_id = $1 AND question_id = $2`, 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, `DELETE FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO votes (user_id, question_id, value) VALUES ($1, $2, $3)
|
||||
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, `
|
||||
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 = $1`, 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, `
|
||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)
|
||||
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, `UPDATE questions SET hidden = 1 WHERE id = $1`, 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, `UPDATE users SET state = $1 WHERE id = $2`, state, userID)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, state, avatarURL, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
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 = $1 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, `
|
||||
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 = $1 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()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrLastAdmin is returned when demoting the only remaining admin.
|
||||
var ErrLastAdmin = errors.New("cannot demote the last admin")
|
||||
|
||||
// Role is a user privilege level stored in users.role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// User is an account row. Methods run SQL against db.
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role Role
|
||||
AvatarURL string
|
||||
State string
|
||||
CreatedAt string
|
||||
PasswordHash string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUser returns a User bound to db (not yet inserted).
|
||||
func NewUser(db *sql.DB) *User {
|
||||
return &User{db: db}
|
||||
}
|
||||
|
||||
func (u *User) Admin() bool {
|
||||
return u != nil && u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func NormalizeUsername(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// Create inserts the user. Sets ID, Name, and CreatedAt when empty.
|
||||
func (u *User) Create(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
if u.Role != RoleUser && u.Role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := u.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`,
|
||||
u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRole updates this user's role (last-admin safe).
|
||||
func (u *User) SetRole(ctx context.Context, role Role) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
if role != RoleUser && role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
tx, err := u.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var current string
|
||||
err = tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = $1`, u.ID).Scan(¤t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if Role(current) == RoleAdmin && role == RoleUser {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(role), u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aff, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if aff == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Role = role
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveProfile writes State and optionally AvatarURL.
|
||||
func (u *User) SaveProfile(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
u.State = strings.TrimSpace(u.State)
|
||||
if u.AvatarURL == "" {
|
||||
_, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1 WHERE id = $2`, u.State, u.ID)
|
||||
return err
|
||||
}
|
||||
_, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, u.State, u.AvatarURL, u.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
|
||||
rows, err := db.QueryContext(ctx, `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
|
||||
var role string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = Role(role)
|
||||
u.db = db
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
||||
return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false)
|
||||
}
|
||||
|
||||
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
|
||||
return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true)
|
||||
}
|
||||
|
||||
func scanUser(db *sql.DB, row *sql.Row, withSecrets bool) (*User, error) {
|
||||
var u User
|
||||
var role string
|
||||
var err error
|
||||
if withSecrets {
|
||||
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash)
|
||||
} else {
|
||||
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = Role(role)
|
||||
u.db = db
|
||||
return &u, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Vote toggles or sets a user's vote on a question (value must be 1 or -1).
|
||||
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
|
||||
if value != 1 && value != -1 {
|
||||
return fmt.Errorf("invalid vote")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT value FROM votes WHERE user_id = $1 AND question_id = $2`, 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, `DELETE FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO votes (user_id, question_id, value) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`, userID, questionID, value)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user