package store import ( "context" "database/sql" "fmt" "sort" "strings" "sync" "time" "github.com/google/uuid" "plumber/internal/pacific" ) // Memory is an in-process Store for tests. type Memory struct { mu sync.Mutex users map[string]*User // id -> user byName map[string]string // username -> id questions map[string]*RankedQuestion // id -> question answers map[string]*Answer // questionID -> answer votes map[string]map[string]int // questionID -> userID -> value } // NewMemory returns an empty Memory store. func NewMemory() *Memory { return &Memory{ users: map[string]*User{}, byName: map[string]string{}, questions: map[string]*RankedQuestion{}, answers: map[string]*Answer{}, votes: map[string]map[string]int{}, } } func (m *Memory) CreateUser(_ context.Context, u *User) error { m.mu.Lock() defer m.mu.Unlock() if u.Role != RoleUser && u.Role != RoleAdmin { return fmt.Errorf("invalid role") } u.Username = NormalizeUsername(u.Username) u.Email = NormalizeEmail(u.Email) if _, ok := m.byName[u.Username]; ok { return ErrDuplicateUsername } if u.Email != "" { for _, existing := range m.users { if existing.Email == u.Email { return ErrDuplicateEmail } } } 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) } role := u.Role if role == RoleAdmin { for _, existing := range m.users { if existing.Role == RoleAdmin { role = RoleUser break } } } cp := *u cp.Role = role cp.db = nil m.users[cp.ID] = &cp m.byName[cp.Username] = cp.ID *u = cp return nil } func (m *Memory) UserByID(_ context.Context, id string) (*User, error) { m.mu.Lock() defer m.mu.Unlock() u, ok := m.users[id] if !ok { return nil, sql.ErrNoRows } cp := *u return &cp, nil } func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) { m.mu.Lock() defer m.mu.Unlock() id, ok := m.byName[NormalizeUsername(username)] if !ok { return nil, sql.ErrNoRows } cp := *m.users[id] return &cp, nil } func (m *Memory) ListUsers(_ context.Context, q ListUsersQuery) ([]User, string, string, error) { m.mu.Lock() defer m.mu.Unlock() limit := q.Limit if limit <= 0 { limit = AdminUsersLimit } search := strings.ToLower(strings.TrimSpace(q.Search)) out := make([]User, 0, len(m.users)) for _, u := range m.users { if search != "" && !strings.Contains(strings.ToLower(u.Username), search) && !strings.Contains(strings.ToLower(u.Name), search) { continue } if q.CursorCreated != "" { if u.CreatedAt > q.CursorCreated { continue } if u.CreatedAt == q.CursorCreated && u.ID >= q.CursorID { continue } } out = append(out, *u) } sort.Slice(out, func(i, j int) bool { if out[i].CreatedAt != out[j].CreatedAt { return out[i].CreatedAt > out[j].CreatedAt } return out[i].ID > out[j].ID }) var nextCreated, nextID string if len(out) > limit { last := out[limit-1] nextCreated, nextID = last.CreatedAt, last.ID out = out[:limit] } return out, nextCreated, nextID, nil } func (m *Memory) CountAdmins(_ context.Context) (int, error) { m.mu.Lock() defer m.mu.Unlock() n := 0 for _, u := range m.users { if u.Role == RoleAdmin { n++ } } return n, nil } // SetUserRole serializes demotions under m.mu (same critical section as count). func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error { m.mu.Lock() defer m.mu.Unlock() if role != RoleUser && role != RoleAdmin { return fmt.Errorf("invalid role") } u, ok := m.users[id] if !ok { return sql.ErrNoRows } if u.Role == RoleAdmin && role == RoleUser { n := 0 for _, x := range m.users { if x.Role == RoleAdmin { n++ } } if n <= 1 { return ErrLastAdmin } } u.Role = role return nil } func (m *Memory) SaveUserProfile(_ context.Context, u *User) error { m.mu.Lock() defer m.mu.Unlock() cur, ok := m.users[u.ID] if !ok { return sql.ErrNoRows } email := NormalizeEmail(u.Email) if email != "" { for id, existing := range m.users { if id != u.ID && existing.Email == email { return ErrDuplicateEmail } } } cur.State = strings.TrimSpace(u.State) cur.Email = email if u.AvatarURL != "" { cur.AvatarURL = u.AvatarURL } u.State = cur.State u.Email = cur.Email u.AvatarURL = cur.AvatarURL return nil } func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error { m.mu.Lock() defer m.mu.Unlock() q.Title = strings.TrimSpace(q.Title) q.Body = strings.TrimSpace(q.Body) q.City = strings.TrimSpace(q.City) if q.ID == "" { q.ID = uuid.NewString() } if q.HuntDate == "" { q.HuntDate = pacific.Today() } if q.CreatedAt == "" { q.CreatedAt = time.Now().UTC().Format(time.RFC3339) } author, ok := m.users[q.AuthorID] if !ok { return fmt.Errorf("unknown author") } cp := *q cp.AuthorName = author.Name cp.db = nil m.questions[cp.ID] = &cp *q = cp return nil } func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion { out := *q score := 0 userVote := 0 if votes, ok := m.votes[q.ID]; ok { for uid, v := range votes { score += v if uid == viewerID { userVote = v } } } _, answered := m.answers[q.ID] out.Score = score out.Answered = answered out.UserVote = userVote out.db = nil return out } func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) { m.mu.Lock() defer m.mu.Unlock() q, ok := m.questions[id] if !ok { return nil, sql.ErrNoRows } out := m.annotate(q, viewerID) return &out, nil } func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) { m.mu.Lock() defer m.mu.Unlock() out := make([]RankedQuestion, 0) for _, q := range m.questions { if q.HuntDate != huntDate || q.Hidden { continue } out = append(out, m.annotate(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 }) if len(out) > HuntListLimit { out = out[:HuntListLimit] } return out, nil } func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) { m.mu.Lock() defer m.mu.Unlock() out := make([]RankedQuestion, 0) for _, q := range m.questions { if q.AuthorID != authorID || q.Hidden { continue } out = append(out, m.annotate(q, "")) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) if len(out) > ProfileListLimit { out = out[:ProfileListLimit] } return out, nil } func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) { m.mu.Lock() defer m.mu.Unlock() out := make([]RankedQuestion, 0) for qid, a := range m.answers { if a.AuthorID != adminID { continue } q, ok := m.questions[qid] if !ok || q.Hidden { continue } rq := m.annotate(q, "") rq.Answered = true out = append(out, rq) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) if len(out) > ProfileListLimit { out = out[:ProfileListLimit] } return out, nil } func (m *Memory) 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 *Memory) GetAnswer(_ context.Context, questionID string) (*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 *Memory) UpsertAnswer(_ context.Context, a *Answer) error { m.mu.Lock() defer m.mu.Unlock() if _, ok := m.questions[a.QuestionID]; !ok { return fmt.Errorf("unknown question") } a.Body = strings.TrimSpace(a.Body) now := time.Now().UTC().Format(time.RFC3339) if existing, ok := m.answers[a.QuestionID]; ok { a.CreatedAt = existing.CreatedAt } else if a.CreatedAt == "" { a.CreatedAt = now } a.UpdatedAt = now cp := *a cp.db = nil m.answers[a.QuestionID] = &cp *a = cp return nil } func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error { m.mu.Lock() defer m.mu.Unlock() if value != 1 && value != -1 && value != 0 { return fmt.Errorf("invalid vote") } q, ok := m.questions[questionID] if !ok || q.Hidden { return ErrHiddenOrMissing } if m.votes[questionID] == nil { m.votes[questionID] = map[string]int{} } if value == 0 { delete(m.votes[questionID], userID) return nil } m.votes[questionID][userID] = value return nil }