CI / test (pull_request) Successful in 8m23s
Render reply trees with permission-aware inline controls, and move hunt, profile, voting, submission, and hiding flows to post storage.
489 lines
11 KiB
Go
489 lines
11 KiB
Go
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)
|
|
}
|
|
|
|
// GetPostThreadForViewer includes root voting and answered annotations.
|
|
func GetPostThreadForViewer(
|
|
ctx context.Context,
|
|
db *sql.DB,
|
|
rootID string,
|
|
viewerID string,
|
|
) (*Post, error) {
|
|
root, err := GetPostThread(ctx, db, rootID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary, err := sqlc.New(db).GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
|
|
ViewerID: viewerID,
|
|
RootID: rootID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root.Score = int(summary.Score)
|
|
root.UserVote = int(summary.UserVote)
|
|
root.Answered = postTreeContainsRole(root, RoleAdmin)
|
|
return root, nil
|
|
}
|
|
|
|
func postTreeContainsRole(post *Post, role Role) bool {
|
|
for _, reply := range post.Replies {
|
|
if reply.AuthorRole == role || postTreeContainsRole(reply, role) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ListRootPostsByAuthor returns visible roots created by an author, newest first.
|
|
func ListRootPostsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]Post, error) {
|
|
rows, err := sqlc.New(db).ListRootPostsByAuthor(ctx, sqlc.ListRootPostsByAuthorParams{
|
|
AuthorID: authorID,
|
|
HiddenState: string(PostStateHidden),
|
|
RowLimit: ProfileListLimit,
|
|
})
|
|
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 posts, nil
|
|
}
|
|
|
|
// ListRootPostsAnsweredBy returns visible roots containing a reply by adminID.
|
|
func ListRootPostsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]Post, error) {
|
|
rows, err := sqlc.New(db).ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
|
|
HiddenState: string(PostStateHidden),
|
|
AdminID: adminID,
|
|
RowLimit: ProfileListLimit,
|
|
})
|
|
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 posts, nil
|
|
}
|
|
|
|
// SetRootPostState changes a root post's state.
|
|
func SetRootPostState(ctx context.Context, db *sql.DB, id string, state PostState) error {
|
|
switch state {
|
|
case PostStateVisible, PostStateHidden, PostStateLocked:
|
|
default:
|
|
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
|
|
}
|
|
n, err := sqlc.New(db).UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
|
|
PostState: string(state),
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
ID: id,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return 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
|
|
}
|