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() }