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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user