330 lines
7.4 KiB
Go
330 lines
7.4 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"plumber/internal/pacific"
|
|
"plumber/internal/store"
|
|
)
|
|
|
|
// memDB is an in-memory store.DB for tests.
|
|
type memDB struct {
|
|
mu sync.Mutex
|
|
users map[string]*store.User // id -> user
|
|
byName map[string]string // username -> id
|
|
questions map[string]*store.RankedQuestion // id -> question
|
|
votes map[string]int // userID|questionID -> value
|
|
answers map[string]*store.Answer // questionID -> answer
|
|
}
|
|
|
|
func newMemDB() *memDB {
|
|
return &memDB{
|
|
users: map[string]*store.User{},
|
|
byName: map[string]string{},
|
|
questions: map[string]*store.RankedQuestion{},
|
|
votes: map[string]int{},
|
|
answers: map[string]*store.Answer{},
|
|
}
|
|
}
|
|
|
|
func voteKey(userID, questionID string) string {
|
|
return userID + "|" + questionID
|
|
}
|
|
|
|
func (m *memDB) CreateUser(_ context.Context, nu store.NewUser) (*store.User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
username := store.NormalizeUsername(nu.Username)
|
|
if _, ok := m.byName[username]; ok {
|
|
return nil, fmt.Errorf("username taken")
|
|
}
|
|
if nu.Role != store.RoleUser && nu.Role != store.RoleAdmin {
|
|
return nil, fmt.Errorf("invalid role")
|
|
}
|
|
u := &store.User{
|
|
ID: uuid.NewString(),
|
|
Username: username,
|
|
Name: username,
|
|
Role: nu.Role,
|
|
PasswordHash: nu.PasswordHash,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
m.users[u.ID] = u
|
|
m.byName[username] = u.ID
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *memDB) UserByID(_ context.Context, id string) (*store.User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
u, ok := m.users[id]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *u
|
|
cp.PasswordHash = ""
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *memDB) UserByUsername(_ context.Context, username string) (*store.User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
id, ok := m.byName[store.NormalizeUsername(username)]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *m.users[id]
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *memDB) CountAdmins(_ context.Context) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
n := 0
|
|
for _, u := range m.users {
|
|
if u.Role == store.RoleAdmin {
|
|
n++
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (m *memDB) ListUsers(_ context.Context) ([]store.User, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]store.User, 0, len(m.users))
|
|
for _, u := range m.users {
|
|
cp := *u
|
|
cp.PasswordHash = ""
|
|
out = append(out, cp)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].CreatedAt < out[j].CreatedAt
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memDB) SetRole(_ context.Context, userID string, role store.Role) error {
|
|
if role != store.RoleUser && role != store.RoleAdmin {
|
|
return fmt.Errorf("invalid role")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
u, ok := m.users[userID]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
if u.Role == store.RoleAdmin && role == store.RoleUser {
|
|
n := 0
|
|
for _, x := range m.users {
|
|
if x.Role == store.RoleAdmin {
|
|
n++
|
|
}
|
|
}
|
|
if n <= 1 {
|
|
return store.ErrLastAdmin
|
|
}
|
|
}
|
|
u.Role = role
|
|
return nil
|
|
}
|
|
|
|
func (m *memDB) CreateQuestion(_ context.Context, authorID, title, body, city string) (*store.RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
author, ok := m.users[authorID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown author")
|
|
}
|
|
q := &store.RankedQuestion{
|
|
ID: uuid.NewString(),
|
|
AuthorID: authorID,
|
|
AuthorName: author.Name,
|
|
Title: strings.TrimSpace(title),
|
|
Body: strings.TrimSpace(body),
|
|
City: strings.TrimSpace(city),
|
|
HuntDate: pacific.Today(),
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
m.questions[q.ID] = q
|
|
cp := *q
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *memDB) rankedLocked(q *store.RankedQuestion, viewerID string) store.RankedQuestion {
|
|
out := *q
|
|
score := 0
|
|
for k, v := range m.votes {
|
|
_, qid, ok := strings.Cut(k, "|")
|
|
if ok && qid == q.ID {
|
|
score += v
|
|
}
|
|
}
|
|
out.Score = score
|
|
out.Answered = m.answers[q.ID] != nil
|
|
if viewerID != "" {
|
|
out.UserVote = m.votes[voteKey(viewerID, q.ID)]
|
|
}
|
|
if u, ok := m.users[q.AuthorID]; ok {
|
|
out.AuthorName = u.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *memDB) ListHunt(_ context.Context, huntDate, viewerID string) ([]store.RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []store.RankedQuestion
|
|
for _, q := range m.questions {
|
|
if q.HuntDate != huntDate || q.Hidden {
|
|
continue
|
|
}
|
|
out = append(out, m.rankedLocked(q, viewerID))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Score != out[j].Score {
|
|
return out[i].Score > out[j].Score
|
|
}
|
|
return out[i].CreatedAt < out[j].CreatedAt
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memDB) GetQuestion(_ context.Context, id, viewerID string) (*store.RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
q, ok := m.questions[id]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
out := m.rankedLocked(q, viewerID)
|
|
return &out, nil
|
|
}
|
|
|
|
func (m *memDB) Vote(_ context.Context, userID, questionID string, value int) error {
|
|
if value != 1 && value != -1 {
|
|
return fmt.Errorf("invalid vote")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.questions[questionID]; !ok {
|
|
return fmt.Errorf("unknown question")
|
|
}
|
|
k := voteKey(userID, questionID)
|
|
if cur, ok := m.votes[k]; ok && cur == value {
|
|
delete(m.votes, k)
|
|
return nil
|
|
}
|
|
m.votes[k] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *memDB) GetAnswer(_ context.Context, questionID string) (*store.Answer, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
a, ok := m.answers[questionID]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *a
|
|
if u, ok := m.users[a.AuthorID]; ok {
|
|
cp.AuthorName = u.Name
|
|
}
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *memDB) UpsertAnswer(_ context.Context, questionID, authorID, body string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
body = strings.TrimSpace(body)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if existing, ok := m.answers[questionID]; ok {
|
|
existing.Body = body
|
|
existing.AuthorID = authorID
|
|
existing.UpdatedAt = now
|
|
return nil
|
|
}
|
|
m.answers[questionID] = &store.Answer{
|
|
QuestionID: questionID,
|
|
AuthorID: authorID,
|
|
Body: body,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *memDB) HideQuestion(_ context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
q, ok := m.questions[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
q.Hidden = true
|
|
return nil
|
|
}
|
|
|
|
func (m *memDB) UpdateProfile(_ context.Context, userID, state, avatarURL string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
u, ok := m.users[userID]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
u.State = state
|
|
if avatarURL != "" {
|
|
u.AvatarURL = avatarURL
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *memDB) ListQuestionsByAuthor(_ context.Context, authorID string) ([]store.RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []store.RankedQuestion
|
|
for _, q := range m.questions {
|
|
if q.AuthorID != authorID || q.Hidden {
|
|
continue
|
|
}
|
|
out = append(out, m.rankedLocked(q, ""))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].CreatedAt > out[j].CreatedAt
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memDB) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]store.RankedQuestion, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []store.RankedQuestion
|
|
for qid, a := range m.answers {
|
|
if a.AuthorID != adminID {
|
|
continue
|
|
}
|
|
q, ok := m.questions[qid]
|
|
if !ok || q.Hidden {
|
|
continue
|
|
}
|
|
rq := m.rankedLocked(q, "")
|
|
rq.Answered = true
|
|
out = append(out, rq)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].CreatedAt > out[j].CreatedAt
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
var _ store.DB = (*memDB)(nil)
|