Files
plumber/internal/store/store.go
T

372 lines
11 KiB
Go

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