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:
@@ -0,0 +1,372 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPost = errors.New("invalid post")
|
||||
ErrPostNotVotable = errors.New("post not votable")
|
||||
)
|
||||
|
||||
type PostState string
|
||||
|
||||
const (
|
||||
PostStateVisible PostState = "visible"
|
||||
PostStateHidden PostState = "hidden"
|
||||
PostStateLocked PostState = "locked"
|
||||
)
|
||||
|
||||
// Post is either a root question (ParentID nil) or a reply to another post.
|
||||
type Post struct {
|
||||
ID string
|
||||
ParentID *string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
AuthorRole Role
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
PostState PostState
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Score int
|
||||
Answered bool
|
||||
UserVote int
|
||||
Replies []*Post
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPost returns a post bound to db.
|
||||
func NewPost(db *sql.DB) *Post {
|
||||
return &Post{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a root post or reply according to ParentID.
|
||||
func (p *Post) Create(ctx context.Context) error {
|
||||
if p == nil || p.db == nil {
|
||||
return fmt.Errorf("post: no database")
|
||||
}
|
||||
if err := preparePost(p); err != nil {
|
||||
return err
|
||||
}
|
||||
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
|
||||
ID: p.ID,
|
||||
ParentID: nullableParentID(p.ParentID),
|
||||
AuthorID: p.AuthorID,
|
||||
Title: p.Title,
|
||||
Body: p.Body,
|
||||
City: p.City,
|
||||
PostDate: p.PostDate,
|
||||
PostState: string(p.PostState),
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
})
|
||||
return mapPostCreateError(err)
|
||||
}
|
||||
|
||||
// Update changes only the post body and update timestamp.
|
||||
func (p *Post) Update(ctx context.Context) error {
|
||||
if p == nil || p.db == nil {
|
||||
return fmt.Errorf("post: no database")
|
||||
}
|
||||
p.Body = strings.TrimSpace(p.Body)
|
||||
if p.Body == "" {
|
||||
return fmt.Errorf("%w: body is required", ErrInvalidPost)
|
||||
}
|
||||
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
|
||||
ID: p.ID,
|
||||
Body: p.Body,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePost(p *Post) error {
|
||||
p.ID = strings.TrimSpace(p.ID)
|
||||
p.AuthorID = strings.TrimSpace(p.AuthorID)
|
||||
p.Title = strings.TrimSpace(p.Title)
|
||||
p.Body = strings.TrimSpace(p.Body)
|
||||
p.City = strings.TrimSpace(p.City)
|
||||
p.PostDate = strings.TrimSpace(p.PostDate)
|
||||
if p.PostState == "" {
|
||||
p.PostState = PostStateVisible
|
||||
}
|
||||
switch p.PostState {
|
||||
case PostStateVisible, PostStateHidden, PostStateLocked:
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
|
||||
}
|
||||
if p.AuthorID == "" {
|
||||
return fmt.Errorf("%w: author is required", ErrInvalidPost)
|
||||
}
|
||||
if p.Body == "" {
|
||||
return fmt.Errorf("%w: body is required", ErrInvalidPost)
|
||||
}
|
||||
if p.ParentID == nil {
|
||||
if p.Title == "" {
|
||||
return fmt.Errorf("%w: root title is required", ErrInvalidPost)
|
||||
}
|
||||
if p.PostDate == "" {
|
||||
p.PostDate = pacific.Today()
|
||||
}
|
||||
} else {
|
||||
parentID := strings.TrimSpace(*p.ParentID)
|
||||
if parentID == "" {
|
||||
return fmt.Errorf("%w: parent is required", ErrInvalidPost)
|
||||
}
|
||||
p.ParentID = &parentID
|
||||
if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible {
|
||||
return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
|
||||
}
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = uuid.NewString()
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if p.CreatedAt == "" {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt == "" {
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableParentID(parentID *string) sql.NullString {
|
||||
if parentID == nil {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: *parentID, Valid: true}
|
||||
}
|
||||
|
||||
func parentIDFromNull(parentID sql.NullString) *string {
|
||||
if !parentID.Valid {
|
||||
return nil
|
||||
}
|
||||
id := parentID.String
|
||||
return &id
|
||||
}
|
||||
|
||||
func mapPostCreateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "23503", "23505", "23514":
|
||||
return fmt.Errorf("%w: %v", ErrInvalidPost, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func postFromValues(
|
||||
db *sql.DB,
|
||||
id string,
|
||||
parentID sql.NullString,
|
||||
authorID, authorName, authorRole, title, body, city, postDate string,
|
||||
postState string,
|
||||
createdAt, updatedAt string,
|
||||
) Post {
|
||||
return Post{
|
||||
ID: id,
|
||||
ParentID: parentIDFromNull(parentID),
|
||||
AuthorID: authorID,
|
||||
AuthorName: authorName,
|
||||
AuthorRole: Role(authorRole),
|
||||
Title: title,
|
||||
Body: body,
|
||||
City: city,
|
||||
PostDate: postDate,
|
||||
PostState: PostState(postState),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPost returns one post without loading its replies.
|
||||
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
|
||||
r, err := sqlc.New(db).GetPost(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := postFromValues(
|
||||
db,
|
||||
r.ID,
|
||||
r.ParentID,
|
||||
r.AuthorID,
|
||||
r.AuthorName,
|
||||
r.AuthorRole,
|
||||
r.Title,
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetPostThread returns a root post with all descendants nested under Replies.
|
||||
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
|
||||
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := make([]Post, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
posts = append(posts, postFromValues(
|
||||
db,
|
||||
r.ID,
|
||||
r.ParentID,
|
||||
r.AuthorID,
|
||||
r.AuthorName,
|
||||
r.AuthorRole,
|
||||
r.Title,
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
))
|
||||
}
|
||||
return buildPostTree(posts, rootID)
|
||||
}
|
||||
|
||||
func buildPostTree(posts []Post, rootID string) (*Post, error) {
|
||||
byID := make(map[string]*Post, len(posts))
|
||||
for i := range posts {
|
||||
posts[i].Replies = nil
|
||||
byID[posts[i].ID] = &posts[i]
|
||||
}
|
||||
root, ok := byID[rootID]
|
||||
if !ok || root.ParentID != nil {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
for i := range posts {
|
||||
post := &posts[i]
|
||||
if post.ID == rootID {
|
||||
continue
|
||||
}
|
||||
if post.ParentID == nil {
|
||||
return nil, fmt.Errorf("post %s is not in thread %s", post.ID, rootID)
|
||||
}
|
||||
parent, ok := byID[*post.ParentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("post %s has missing parent %s", post.ID, *post.ParentID)
|
||||
}
|
||||
parent.Replies = append(parent.Replies, post)
|
||||
}
|
||||
var sortReplies func(*Post)
|
||||
sortReplies = func(post *Post) {
|
||||
sort.Slice(post.Replies, func(i, j int) bool {
|
||||
if post.Replies[i].CreatedAt != post.Replies[j].CreatedAt {
|
||||
return post.Replies[i].CreatedAt < post.Replies[j].CreatedAt
|
||||
}
|
||||
return post.Replies[i].ID < post.Replies[j].ID
|
||||
})
|
||||
for _, reply := range post.Replies {
|
||||
sortReplies(reply)
|
||||
}
|
||||
}
|
||||
sortReplies(root)
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// ListRootPosts returns visible root posts for a post date.
|
||||
func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) {
|
||||
rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{
|
||||
ViewerID: viewerID,
|
||||
RowLimit: HuntListLimit,
|
||||
PostDate: postDate,
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := make([]Post, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
post := postFromValues(
|
||||
db,
|
||||
r.ID,
|
||||
r.ParentID,
|
||||
r.AuthorID,
|
||||
r.AuthorName,
|
||||
r.AuthorRole,
|
||||
r.Title,
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
)
|
||||
post.Score = int(r.Score)
|
||||
post.Answered = r.Answered
|
||||
post.UserVote = int(r.UserVote)
|
||||
posts = append(posts, post)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// SetPostVote sets value to 1, -1, or 0 on a visible root post.
|
||||
func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error {
|
||||
if value != 1 && value != -1 && value != 0 {
|
||||
return fmt.Errorf("invalid vote")
|
||||
}
|
||||
q := sqlc.New(db)
|
||||
if value == 0 {
|
||||
visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
|
||||
ID: postID,
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visible {
|
||||
return ErrPostNotVotable
|
||||
}
|
||||
return q.DeletePostVote(ctx, sqlc.DeletePostVoteParams{
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
})
|
||||
}
|
||||
n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
Value: int32(value),
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrPostNotVotable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user