Initial commit: runnable Ask a Plumber First server.

This commit is contained in:
2026-08-21 23:30:15 -07:00
parent 7bc79af954
commit d167b9216a
38 changed files with 4174 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
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, username, passwordHash string, asAdmin bool) (*store.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
username = store.NormalizeUsername(username)
if _, ok := m.byName[username]; ok {
return nil, fmt.Errorf("username taken")
}
role := "user"
if asAdmin {
role = "admin"
}
u := &store.User{
ID: uuid.NewString(),
Username: username,
Name: username,
Role: role,
PasswordHash: 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 == "admin" {
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, role string) error {
if role != "user" && role != "admin" {
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 == "admin" && role == "user" {
n := 0
for _, x := range m.users {
if x.Role == "admin" {
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)