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 }