Refactor Store into SessionStore; move domain SQL onto User/Question/Answer.

This commit is contained in:
2026-08-22 02:40:51 -07:00
parent f31f352838
commit c77298411e
15 changed files with 696 additions and 876 deletions
+183
View File
@@ -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(&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), 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
}