Add unified post storage (#3)

Adds one post creation path for roots and replies, recursive thread loading, body-only updates, root listings, and root-only voting across PostgreSQL and the in-memory store.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-08-27 07:43:25 +00:00
committed by codegirl007
parent c5ef15ae1f
commit e86c2072ea
11 changed files with 1764 additions and 30 deletions
+184
View File
@@ -22,6 +22,8 @@ type Memory struct {
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
@@ -32,6 +34,8 @@ func NewMemory() *Memory {
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
}
@@ -372,6 +376,186 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
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) 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) 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
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()