Files
codegirl007 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +00:00

466 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// 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
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
posts: map[string]*Post{},
postVotes: 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) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
if err := preparePost(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
return fmt.Errorf("%w: unknown author", ErrInvalidPost)
}
if _, exists := m.posts[post.ID]; exists {
return fmt.Errorf("%w: duplicate id", ErrInvalidPost)
}
if post.ParentID != nil {
if _, ok := m.posts[*post.ParentID]; !ok {
return fmt.Errorf("%w: unknown parent", ErrInvalidPost)
}
}
cp := clonePost(post)
cp.db = nil
m.posts[cp.ID] = cp
*post = *clonePost(cp)
return nil
}
func (m *Memory) GetPost(_ context.Context, id string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok {
return nil, sql.ErrNoRows
}
return clonePostWithAuthor(post, m.users), nil
}
func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
root, ok := m.posts[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
inThread := map[string]bool{rootID: true}
for changed := true; changed; {
changed = false
for id, post := range m.posts {
if inThread[id] || post.ParentID == nil || !inThread[*post.ParentID] {
continue
}
inThread[id] = true
changed = true
}
}
posts := make([]Post, 0, len(inThread))
for id := range inThread {
posts = append(posts, *clonePostWithAuthor(m.posts[id], m.users))
}
return buildPostTree(posts, rootID)
}
func (m *Memory) GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error) {
root, err := m.GetPostThread(ctx, rootID)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
for _, value := range m.postVotes[rootID] {
root.Score += value
}
root.UserVote = m.postVotes[rootID][viewerID]
root.Answered = m.threadContainsAdminReply(rootID)
return root, nil
}
func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
body := strings.TrimSpace(post.Body)
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
if !ok {
return sql.ErrNoRows
}
existing.Body = body
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
}
func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden {
continue
}
cp := clonePostWithAuthor(post, m.users)
for _, value := range m.postVotes[post.ID] {
cp.Score += value
}
cp.UserVote = m.postVotes[post.ID][viewerID]
cp.Answered = m.threadContainsAdminReply(post.ID)
posts = append(posts, *cp)
}
sort.Slice(posts, func(i, j int) bool {
if posts[i].Score != posts[j].Score {
return posts[i].Score > posts[j].Score
}
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt < posts[j].CreatedAt
}
return posts[i].ID < posts[j].ID
})
if len(posts) > HuntListLimit {
posts = posts[:HuntListLimit]
}
return posts, nil
}
func (m *Memory) ListRootPostsByAuthor(_ context.Context, authorID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil ||
post.AuthorID != authorID ||
post.PostState == PostStateHidden {
continue
}
posts = append(posts, *clonePostWithAuthor(post, m.users))
}
return sortProfilePosts(posts), nil
}
func (m *Memory) ListRootPostsAnsweredBy(_ context.Context, adminID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, root := range m.posts {
if root.ParentID != nil || root.PostState == PostStateHidden {
continue
}
participated := false
for _, post := range m.posts {
if post.AuthorID == adminID && m.postIsDescendantOf(post, root.ID) {
participated = true
break
}
}
if participated {
posts = append(posts, *clonePostWithAuthor(root, m.users))
}
}
return sortProfilePosts(posts), nil
}
func (m *Memory) SetRootPostState(_ context.Context, id string, state PostState) error {
switch state {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok || post.ParentID != nil {
return sql.ErrNoRows
}
post.PostState = state
post.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
return nil
}
func sortProfilePosts(posts []Post) []Post {
sort.Slice(posts, func(i, j int) bool {
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt > posts[j].CreatedAt
}
return posts[i].ID > posts[j].ID
})
if len(posts) > ProfileListLimit {
posts = posts[:ProfileListLimit]
}
return posts
}
func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
post, ok := m.posts[postID]
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
return ErrPostNotVotable
}
if m.postVotes[postID] == nil {
m.postVotes[postID] = map[string]int{}
}
if value == 0 {
delete(m.postVotes[postID], userID)
return nil
}
m.postVotes[postID][userID] = value
return nil
}
func (m *Memory) threadContainsAdminReply(rootID string) bool {
for id, post := range m.posts {
if id == rootID || !m.postIsDescendantOf(post, rootID) {
continue
}
if author := m.users[post.AuthorID]; author != nil && author.Role == RoleAdmin {
return true
}
}
return false
}
func (m *Memory) postIsDescendantOf(post *Post, rootID string) bool {
seen := map[string]bool{}
for post != nil && post.ParentID != nil {
if *post.ParentID == rootID {
return true
}
if seen[*post.ParentID] {
return false
}
seen[*post.ParentID] = true
post = m.posts[*post.ParentID]
}
return false
}
func clonePost(post *Post) *Post {
cp := *post
if post.ParentID != nil {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Replies = nil
return &cp
}
func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
cp := clonePost(post)
if author := users[post.AuthorID]; author != nil {
cp.AuthorName = author.Name
cp.AuthorRole = author.Role
}
return cp
}