package store import ( "context" "database/sql" "errors" "fmt" "math" "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" MaxPostImages = 4 MaxImageDescriptionRunes = 500 ) // PostImage is one ordered public image attached to a post. type PostImage struct { ID string PostID string ObjectKey string PublicURL string Description string Position int Width int Height int CreatedAt string } // 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 Images []PostImage 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 } if err := preparePostImages(p); err != nil { return err } tx, err := p.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() q := sqlc.New(tx) if err := q.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, }); err != nil { return mapPostCreateError(err) } if err := createPostImages(ctx, q, p.Images); err != nil { return mapPostCreateError(err) } return tx.Commit() } // Update changes the post body, update timestamp, and complete image set. 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) if err := preparePostImages(p); err != nil { return err } tx, err := p.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() q := sqlc.New(tx) n, err := q.UpdatePost(ctx, sqlc.UpdatePostParams{ ID: p.ID, Body: p.Body, UpdatedAt: p.UpdatedAt, }) if err != nil { return err } if n == 0 { return sql.ErrNoRows } if err := q.DeletePostImages(ctx, p.ID); err != nil { return err } if err := createPostImages(ctx, q, p.Images); err != nil { return mapPostCreateError(err) } return tx.Commit() } 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 preparePostImages(p *Post) error { if len(p.Images) > MaxPostImages { return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages) } ids := make(map[string]bool, len(p.Images)) keys := make(map[string]bool, len(p.Images)) now := time.Now().UTC().Format(time.RFC3339Nano) for i := range p.Images { image := &p.Images[i] image.ID = strings.TrimSpace(image.ID) image.PostID = strings.TrimSpace(image.PostID) image.ObjectKey = strings.TrimSpace(image.ObjectKey) image.PublicURL = strings.TrimSpace(image.PublicURL) image.Description = strings.TrimSpace(image.Description) if image.ID == "" { image.ID = uuid.NewString() } if image.PostID == "" { image.PostID = p.ID } if image.PostID != p.ID { return fmt.Errorf("%w: image belongs to another post", ErrInvalidPost) } if image.ObjectKey == "" || image.PublicURL == "" { return fmt.Errorf("%w: image storage metadata is required", ErrInvalidPost) } if len([]rune(image.Description)) > MaxImageDescriptionRunes { return fmt.Errorf("%w: image description is too long", ErrInvalidPost) } if image.Width <= 0 || image.Height <= 0 || image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) } if ids[image.ID] || keys[image.ObjectKey] { return fmt.Errorf("%w: duplicate image", ErrInvalidPost) } ids[image.ID] = true keys[image.ObjectKey] = true image.Position = i if image.CreatedAt == "" { image.CreatedAt = now } } return nil } func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage) error { for _, image := range images { if err := q.CreatePostImage(ctx, sqlc.CreatePostImageParams{ ID: image.ID, PostID: image.PostID, ObjectKey: image.ObjectKey, PublicUrl: image.PublicURL, Description: image.Description, Position: int16(image.Position), Width: int32(image.Width), Height: int32(image.Height), CreatedAt: image.CreatedAt, }); err != nil { return err } } return nil } func postImageFromSQL(image sqlc.PostImage) PostImage { return PostImage{ ID: image.ID, PostID: image.PostID, ObjectKey: image.ObjectKey, PublicURL: image.PublicUrl, Description: image.Description, Position: int(image.Position), Width: int(image.Width), Height: int(image.Height), CreatedAt: image.CreatedAt, } } 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) { q := sqlc.New(db) r, err := q.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, ) imageRows, err := q.ListPostImages(ctx, id) if err != nil { return nil, err } for _, image := range imageRows { p.Images = append(p.Images, postImageFromSQL(image)) } 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) { q := sqlc.New(db) rows, err := q.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, )) } imageRows, err := q.ListPostThreadImages(ctx, rootID) if err != nil { return nil, err } postsByID := make(map[string]*Post, len(posts)) for i := range posts { postsByID[posts[i].ID] = &posts[i] } for _, image := range imageRows { post, ok := postsByID[image.PostID] if !ok { return nil, fmt.Errorf("image %s belongs to missing post %s", image.ID, image.PostID) } post.Images = append(post.Images, postImageFromSQL(image)) } 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 }