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)