From fc8f286c3431eb23747e303ed01f46347b6b085d Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 00:10:47 -0700 Subject: [PATCH 1/3] Add unified post storage. Provide one creation path for roots and replies, recursive thread loading, body-only updates, root listings, and post voting across PostgreSQL and the in-memory test store. --- db/queries/posts.sql | 112 +++++++++ internal/store/memory.go | 184 ++++++++++++++ internal/store/migrate_posts_test.go | 59 ++++- internal/store/post.go | 358 +++++++++++++++++++++++++++ internal/store/post_test.go | 209 ++++++++++++++++ internal/store/postgres_store.go | 26 ++ internal/store/sqlc/posts.sql.go | 355 ++++++++++++++++++++++++++ internal/store/store.go | 7 + 8 files changed, 1308 insertions(+), 2 deletions(-) create mode 100644 db/queries/posts.sql create mode 100644 internal/store/post.go create mode 100644 internal/store/post_test.go create mode 100644 internal/store/sqlc/posts.sql.go diff --git a/db/queries/posts.sql b/db/queries/posts.sql new file mode 100644 index 0000000..aa22b07 --- /dev/null +++ b/db/queries/posts.sql @@ -0,0 +1,112 @@ +-- name: CreatePost :exec +INSERT INTO posts ( + id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + +-- name: GetPost :one +SELECT + p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, + p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at +FROM posts p +JOIN users u ON u.id = p.author_id +WHERE p.id = $1; + +-- name: ListPostThread :many +WITH RECURSIVE thread AS ( + SELECT p.* + FROM posts p + WHERE p.id = $1 AND p.parent_id IS NULL + + UNION ALL + + SELECT child.* + FROM posts child + JOIN thread parent ON child.parent_id = parent.id +) +SELECT + thread.id, thread.parent_id, thread.author_id, + u.name AS author_name, u.role AS author_role, + thread.title, thread.body, thread.city, thread.post_date, + thread.hidden, thread.created_at, thread.updated_at +FROM thread +JOIN users u ON u.id = thread.author_id +ORDER BY thread.created_at, thread.id; + +-- name: UpdatePost :execrows +UPDATE posts +SET body = $2, updated_at = $3 +WHERE id = $1; + +-- name: ListRootPosts :many +WITH RECURSIVE roots AS ( + SELECT p.* + FROM posts p + WHERE p.parent_id IS NULL + AND p.post_date = sqlc.arg(post_date) + AND p.hidden = 0 +), +thread AS ( + SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id + FROM roots + + UNION ALL + + SELECT thread.root_id, child.id, child.author_id + FROM thread + JOIN posts child ON child.parent_id = thread.post_id +), +answered AS ( + SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered + FROM thread + JOIN users u ON u.id = thread.author_id + GROUP BY thread.root_id +), +scores AS ( + SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score + FROM post_votes + GROUP BY post_id +) +SELECT + roots.id, roots.parent_id, roots.author_id, + u.name AS author_name, u.role AS author_role, + roots.title, roots.body, roots.city, roots.post_date, + roots.hidden, roots.created_at, roots.updated_at, + COALESCE(scores.score, 0)::bigint AS score, + COALESCE(answered.answered, false)::bool AS answered, + COALESCE(( + SELECT post_votes.value + FROM post_votes + WHERE post_votes.user_id = sqlc.arg(viewer_id) + AND post_votes.post_id = roots.id + ), 0)::bigint AS user_vote +FROM roots +JOIN users u ON u.id = roots.author_id +LEFT JOIN scores ON scores.post_id = roots.id +LEFT JOIN answered ON answered.root_id = roots.id +ORDER BY score DESC, roots.created_at, roots.id +LIMIT sqlc.arg(row_limit); + +-- name: PostIsVisibleRoot :one +SELECT EXISTS( + SELECT 1 + FROM posts + WHERE id = $1 AND parent_id IS NULL AND hidden = 0 +)::bool; + +-- name: DeletePostVote :exec +DELETE FROM post_votes +WHERE user_id = $1 AND post_id = $2; + +-- name: UpsertPostVoteOnVisibleRoot :execrows +INSERT INTO post_votes (user_id, post_id, value) +SELECT $1, $2, $3 +FROM posts p +WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 +ON CONFLICT (user_id, post_id) DO UPDATE +SET value = excluded.value +WHERE EXISTS ( + SELECT 1 + FROM posts p2 + WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0 +); diff --git a/internal/store/memory.go b/internal/store/memory.go index 60e3170..ef615f5 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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.Hidden { + 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.Hidden { + 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() diff --git a/internal/store/migrate_posts_test.go b/internal/store/migrate_posts_test.go index 6a81d37..b4975d2 100644 --- a/internal/store/migrate_posts_test.go +++ b/internal/store/migrate_posts_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/google/uuid" + + "plumber/internal/store/sqlc" ) func TestMigratePostsCopiesLegacyData(t *testing.T) { @@ -47,7 +49,9 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) { legacySchema := ` CREATE TABLE users ( - id TEXT PRIMARY KEY + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL ); CREATE TABLE questions ( id TEXT PRIMARY KEY, @@ -76,7 +80,8 @@ CREATE TABLE votes ( t.Fatal(err) } if _, err := conn.ExecContext(ctx, ` -INSERT INTO users (id) VALUES ('homeowner'), ('plumber'); +INSERT INTO users (id, name, role) +VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z'); INSERT INTO answers (question_id, author_id, body, created_at, updated_at) @@ -183,6 +188,56 @@ INSERT INTO posts ( )`); err == nil { t.Fatal("reply with root-only title unexpectedly succeeded") } + + queries := sqlc.New(conn) + if err := queries.CreatePost(ctx, sqlc.CreatePostParams{ + ID: "follow-up", + ParentID: sql.NullString{String: "answer:question-1", Valid: true}, + AuthorID: "homeowner", + Body: "It is still dripping.", + CreatedAt: "2026-08-26T10:00:00Z", + UpdatedAt: "2026-08-26T10:00:00Z", + }); err != nil { + t.Fatal(err) + } + thread, err := queries.ListPostThread(ctx, "question-1") + if err != nil { + t.Fatal(err) + } + if len(thread) != 3 || + thread[0].ID != "question-1" || + thread[1].ID != "answer:question-1" || + thread[2].ID != "follow-up" { + t.Fatalf("recursive thread = %+v", thread) + } + if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{ + ID: "follow-up", + Body: "The drip continues.", + UpdatedAt: "2026-08-26T10:05:00Z", + }); err != nil || n != 1 { + t.Fatalf("update rows=%d error=%v", n, err) + } + if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ + UserID: "plumber", + PostID: "question-1", + Value: 1, + }); err != nil || n != 1 { + t.Fatalf("vote rows=%d error=%v", n, err) + } + roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{ + ViewerID: "plumber", + RowLimit: 100, + PostDate: "2026-08-26", + }) + if err != nil { + t.Fatal(err) + } + if len(roots) != 1 || + roots[0].Score != 2 || + !roots[0].Answered || + roots[0].UserVote != 1 { + t.Fatalf("root annotations = %+v", roots) + } } func TestMigratePostsReportsStep(t *testing.T) { diff --git a/internal/store/post.go b/internal/store/post.go new file mode 100644 index 0000000..d7c107d --- /dev/null +++ b/internal/store/post.go @@ -0,0 +1,358 @@ +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") +) + +// 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 + Hidden bool + 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, + Hidden: boolInt32(p.Hidden), + 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.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.Hidden { + 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 boolInt32(value bool) int32 { + if value { + return 1 + } + return 0 +} + +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, + hidden int32, + 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, + Hidden: hidden != 0, + 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.Hidden, + 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.Hidden, + 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, + }) + 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.Hidden, + 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, postID) + 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), + }) + if err != nil { + return err + } + if n == 0 { + return ErrPostNotVotable + } + return nil +} diff --git a/internal/store/post_test.go b/internal/store/post_test.go new file mode 100644 index 0000000..a3e66b8 --- /dev/null +++ b/internal/store/post_test.go @@ -0,0 +1,209 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +func TestMapPostCreateError(t *testing.T) { + t.Parallel() + + for _, code := range []string{"23503", "23505", "23514"} { + err := mapPostCreateError(&pgconn.PgError{Code: code}) + if !errors.Is(err, ErrInvalidPost) { + t.Errorf("code %s error = %v, want ErrInvalidPost", code, err) + } + } + original := &pgconn.PgError{Code: "08006"} + if err := mapPostCreateError(original); !errors.Is(err, original) { + t.Errorf("unexpected database error was replaced: %v", err) + } +} + +func TestMemoryPostLifecycle(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mem := NewMemory() + homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser} + plumber := &User{Username: "plumber", PasswordHash: "hash", Role: RoleAdmin} + voter := &User{Username: "voter", PasswordHash: "hash", Role: RoleUser} + for _, user := range []*User{homeowner, plumber, voter} { + if err := mem.CreateUser(ctx, user); err != nil { + t.Fatal(err) + } + } + + root := &Post{ + ID: "root", + AuthorID: homeowner.ID, + Title: "Leaky sink", + Body: "It drips.", + City: "Oakland", + PostDate: "2026-08-26", + CreatedAt: "2026-08-26T08:00:00Z", + } + if err := mem.CreatePost(ctx, root); err != nil { + t.Fatal(err) + } + + rootID := root.ID + later := &Post{ + ID: "later", + ParentID: &rootID, + AuthorID: plumber.ID, + Body: "Is it a single-handle faucet?", + CreatedAt: "2026-08-26T09:00:00Z", + } + earlier := &Post{ + ID: "earlier", + ParentID: &rootID, + AuthorID: plumber.ID, + Body: "Can you share the model number?", + CreatedAt: "2026-08-26T08:30:00Z", + } + if err := mem.CreatePost(ctx, later); err != nil { + t.Fatal(err) + } + if err := mem.CreatePost(ctx, earlier); err != nil { + t.Fatal(err) + } + + laterID := later.ID + nested := &Post{ + ID: "nested", + ParentID: &laterID, + AuthorID: homeowner.ID, + Body: "Yes, it is.", + CreatedAt: "2026-08-26T09:30:00Z", + } + if err := mem.CreatePost(ctx, nested); err != nil { + t.Fatal(err) + } + + thread, err := mem.GetPostThread(ctx, root.ID) + if err != nil { + t.Fatal(err) + } + if thread.AuthorName != homeowner.Name || thread.AuthorRole != RoleUser { + t.Fatalf("root author = %q %q", thread.AuthorName, thread.AuthorRole) + } + if len(thread.Replies) != 2 || + thread.Replies[0].ID != earlier.ID || + thread.Replies[1].ID != later.ID { + t.Fatalf("root replies are not oldest-first: %+v", thread.Replies) + } + if len(thread.Replies[1].Replies) != 1 || thread.Replies[1].Replies[0].ID != nested.ID { + t.Fatalf("nested reply missing: %+v", thread.Replies[1].Replies) + } + + otherParent := earlier.ID + nested.ParentID = &otherParent + nested.AuthorID = voter.ID + nested.Body = "Yes—one handle." + if err := mem.UpdatePost(ctx, nested); err != nil { + t.Fatal(err) + } + saved, err := mem.GetPost(ctx, nested.ID) + if err != nil { + t.Fatal(err) + } + if saved.ParentID == nil || *saved.ParentID != later.ID { + t.Fatalf("update changed parent to %+v", saved.ParentID) + } + if saved.AuthorID != homeowner.ID { + t.Fatalf("update changed author to %q", saved.AuthorID) + } + if saved.Body != "Yes—one handle." || saved.UpdatedAt == saved.CreatedAt { + t.Fatalf("body update not applied: %+v", saved) + } + + if err := mem.VotePost(ctx, voter.ID, root.ID, 1); err != nil { + t.Fatal(err) + } + roots, err := mem.ListRootPosts(ctx, root.PostDate, voter.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 1 || roots[0].ID != root.ID { + t.Fatalf("root list = %+v", roots) + } + if roots[0].Score != 1 || roots[0].UserVote != 1 || !roots[0].Answered { + t.Fatalf("root annotations = %+v", roots[0]) + } + if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) { + t.Fatalf("reply vote error = %v", err) + } +} + +func TestMemoryPostValidation(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mem := NewMemory() + homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser} + if err := mem.CreateUser(ctx, homeowner); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + post *Post + }{ + { + name: "root without title", + post: &Post{AuthorID: homeowner.ID, Body: "Body"}, + }, + { + name: "empty parent", + post: &Post{ParentID: ptr(""), AuthorID: homeowner.ID, Body: "Body"}, + }, + { + name: "missing parent", + post: &Post{ParentID: ptr("missing"), AuthorID: homeowner.ID, Body: "Body"}, + }, + { + name: "reply with root fields", + post: &Post{ + ParentID: ptr("missing"), + AuthorID: homeowner.ID, + Title: "Not allowed", + Body: "Body", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := mem.CreatePost(ctx, test.post); !errors.Is(err, ErrInvalidPost) { + t.Fatalf("error = %v, want ErrInvalidPost", err) + } + }) + } + + hidden := &Post{ + ID: "hidden", + AuthorID: homeowner.ID, + Title: "Hidden", + Body: "Body", + PostDate: "2026-08-26", + Hidden: true, + } + if err := mem.CreatePost(ctx, hidden); err != nil { + t.Fatal(err) + } + if err := mem.VotePost(ctx, homeowner.ID, hidden.ID, 1); !errors.Is(err, ErrPostNotVotable) { + t.Fatalf("hidden root vote error = %v", err) + } + + if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("missing thread error = %v", err) + } +} + +func ptr(value string) *string { + return &value +} diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go index a67d906..9b20566 100644 --- a/internal/store/postgres_store.go +++ b/internal/store/postgres_store.go @@ -144,6 +144,32 @@ func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error { return a.Upsert(ctx) } +func (p *Postgres) CreatePost(ctx context.Context, post *Post) error { + post.db = p.db + return post.Create(ctx) +} + +func (p *Postgres) GetPost(ctx context.Context, id string) (*Post, error) { + return GetPost(ctx, p.db, id) +} + +func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, error) { + return GetPostThread(ctx, p.db, rootID) +} + +func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error { + post.db = p.db + return post.Update(ctx) +} + +func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) { + return ListRootPosts(ctx, p.db, postDate, viewerID) +} + +func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error { + return SetPostVote(ctx, p.db, userID, postID, value) +} + func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error { return Vote(ctx, p.db, userID, questionID, value) } diff --git a/internal/store/sqlc/posts.sql.go b/internal/store/sqlc/posts.sql.go new file mode 100644 index 0000000..c0f647a --- /dev/null +++ b/internal/store/sqlc/posts.sql.go @@ -0,0 +1,355 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: posts.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const createPost = `-- name: CreatePost :exec +INSERT INTO posts ( + id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +` + +type CreatePostParams struct { + ID string + ParentID sql.NullString + AuthorID string + Title string + Body string + City string + PostDate string + Hidden int32 + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error { + _, err := q.db.ExecContext(ctx, createPost, + arg.ID, + arg.ParentID, + arg.AuthorID, + arg.Title, + arg.Body, + arg.City, + arg.PostDate, + arg.Hidden, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deletePostVote = `-- name: DeletePostVote :exec +DELETE FROM post_votes +WHERE user_id = $1 AND post_id = $2 +` + +type DeletePostVoteParams struct { + UserID string + PostID string +} + +func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams) error { + _, err := q.db.ExecContext(ctx, deletePostVote, arg.UserID, arg.PostID) + return err +} + +const getPost = `-- name: GetPost :one +SELECT + p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, + p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at +FROM posts p +JOIN users u ON u.id = p.author_id +WHERE p.id = $1 +` + +type GetPostRow struct { + ID string + ParentID sql.NullString + AuthorID string + AuthorName string + AuthorRole string + Title string + Body string + City string + PostDate string + Hidden int32 + CreatedAt string + UpdatedAt string +} + +func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) { + row := q.db.QueryRowContext(ctx, getPost, id) + var i GetPostRow + err := row.Scan( + &i.ID, + &i.ParentID, + &i.AuthorID, + &i.AuthorName, + &i.AuthorRole, + &i.Title, + &i.Body, + &i.City, + &i.PostDate, + &i.Hidden, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listPostThread = `-- name: ListPostThread :many +WITH RECURSIVE thread AS ( + SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + FROM posts p + WHERE p.id = $1 AND p.parent_id IS NULL + + UNION ALL + + SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.hidden, child.created_at, child.updated_at + FROM posts child + JOIN thread parent ON child.parent_id = parent.id +) +SELECT + thread.id, thread.parent_id, thread.author_id, + u.name AS author_name, u.role AS author_role, + thread.title, thread.body, thread.city, thread.post_date, + thread.hidden, thread.created_at, thread.updated_at +FROM thread +JOIN users u ON u.id = thread.author_id +ORDER BY thread.created_at, thread.id +` + +type ListPostThreadRow struct { + ID string + ParentID sql.NullString + AuthorID string + AuthorName string + AuthorRole string + Title string + Body string + City string + PostDate string + Hidden int32 + CreatedAt string + UpdatedAt string +} + +func (q *Queries) ListPostThread(ctx context.Context, id string) ([]ListPostThreadRow, error) { + rows, err := q.db.QueryContext(ctx, listPostThread, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListPostThreadRow{} + for rows.Next() { + var i ListPostThreadRow + if err := rows.Scan( + &i.ID, + &i.ParentID, + &i.AuthorID, + &i.AuthorName, + &i.AuthorRole, + &i.Title, + &i.Body, + &i.City, + &i.PostDate, + &i.Hidden, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRootPosts = `-- name: ListRootPosts :many +WITH RECURSIVE roots AS ( + SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + FROM posts p + WHERE p.parent_id IS NULL + AND p.post_date = $3 + AND p.hidden = 0 +), +thread AS ( + SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id + FROM roots + + UNION ALL + + SELECT thread.root_id, child.id, child.author_id + FROM thread + JOIN posts child ON child.parent_id = thread.post_id +), +answered AS ( + SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered + FROM thread + JOIN users u ON u.id = thread.author_id + GROUP BY thread.root_id +), +scores AS ( + SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score + FROM post_votes + GROUP BY post_id +) +SELECT + roots.id, roots.parent_id, roots.author_id, + u.name AS author_name, u.role AS author_role, + roots.title, roots.body, roots.city, roots.post_date, + roots.hidden, roots.created_at, roots.updated_at, + COALESCE(scores.score, 0)::bigint AS score, + COALESCE(answered.answered, false)::bool AS answered, + COALESCE(( + SELECT post_votes.value + FROM post_votes + WHERE post_votes.user_id = $1 + AND post_votes.post_id = roots.id + ), 0)::bigint AS user_vote +FROM roots +JOIN users u ON u.id = roots.author_id +LEFT JOIN scores ON scores.post_id = roots.id +LEFT JOIN answered ON answered.root_id = roots.id +ORDER BY score DESC, roots.created_at, roots.id +LIMIT $2 +` + +type ListRootPostsParams struct { + ViewerID string + RowLimit int32 + PostDate string +} + +type ListRootPostsRow struct { + ID string + ParentID sql.NullString + AuthorID string + AuthorName string + AuthorRole string + Title string + Body string + City string + PostDate string + Hidden int32 + CreatedAt string + UpdatedAt string + Score int64 + Answered bool + UserVote int64 +} + +func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) { + rows, err := q.db.QueryContext(ctx, listRootPosts, arg.ViewerID, arg.RowLimit, arg.PostDate) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRootPostsRow{} + for rows.Next() { + var i ListRootPostsRow + if err := rows.Scan( + &i.ID, + &i.ParentID, + &i.AuthorID, + &i.AuthorName, + &i.AuthorRole, + &i.Title, + &i.Body, + &i.City, + &i.PostDate, + &i.Hidden, + &i.CreatedAt, + &i.UpdatedAt, + &i.Score, + &i.Answered, + &i.UserVote, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one +SELECT EXISTS( + SELECT 1 + FROM posts + WHERE id = $1 AND parent_id IS NULL AND hidden = 0 +)::bool +` + +func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error) { + row := q.db.QueryRowContext(ctx, postIsVisibleRoot, id) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const updatePost = `-- name: UpdatePost :execrows +UPDATE posts +SET body = $2, updated_at = $3 +WHERE id = $1 +` + +type UpdatePostParams struct { + ID string + Body string + UpdatedAt string +} + +func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updatePost, arg.ID, arg.Body, arg.UpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows +INSERT INTO post_votes (user_id, post_id, value) +SELECT $1, $2, $3 +FROM posts p +WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 +ON CONFLICT (user_id, post_id) DO UPDATE +SET value = excluded.value +WHERE EXISTS ( + SELECT 1 + FROM posts p2 + WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0 +) +` + +type UpsertPostVoteOnVisibleRootParams struct { + UserID string + PostID string + Value int32 +} + +func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) { + result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot, arg.UserID, arg.PostID, arg.Value) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/store/store.go b/internal/store/store.go index 2faf5be..8d496ea 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -37,6 +37,13 @@ type Store interface { GetAnswer(ctx context.Context, questionID string) (*Answer, error) UpsertAnswer(ctx context.Context, a *Answer) error + CreatePost(ctx context.Context, post *Post) error + GetPost(ctx context.Context, id string) (*Post, error) + GetPostThread(ctx context.Context, rootID string) (*Post, error) + UpdatePost(ctx context.Context, post *Post) error + ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) + VotePost(ctx context.Context, userID, postID string, value int) error + // Vote sets the vote to 1, -1, or 0 (clear) on a visible question. Vote(ctx context.Context, userID, questionID string, value int) error } -- 2.43.0 From e918e5bd1da17cbba06a70d6ca946d11a87bffd2 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 00:22:07 -0700 Subject: [PATCH 2/3] Simplify post listing queries. Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths. --- db/queries/posts.sql | 67 ++++++++++++---------- internal/store/migrate.go | 51 +++++++++++++++++ internal/store/migrate_posts_test.go | 84 ++++++++++++++++++++++++++++ internal/store/sqlc/posts.sql.go | 65 ++++++++++++--------- schema.sql | 3 + 5 files changed, 215 insertions(+), 55 deletions(-) diff --git a/db/queries/posts.sql b/db/queries/posts.sql index aa22b07..626e109 100644 --- a/db/queries/posts.sql +++ b/db/queries/posts.sql @@ -2,7 +2,18 @@ INSERT INTO posts ( id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); +VALUES ( + sqlc.arg(id), + sqlc.arg(parent_id), + sqlc.arg(author_id), + sqlc.arg(title), + sqlc.arg(body), + sqlc.arg(city), + sqlc.arg(post_date), + sqlc.arg(hidden), + sqlc.arg(created_at), + sqlc.arg(updated_at) +); -- name: GetPost :one SELECT @@ -10,13 +21,13 @@ SELECT p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at FROM posts p JOIN users u ON u.id = p.author_id -WHERE p.id = $1; +WHERE p.id = sqlc.arg(id); -- name: ListPostThread :many WITH RECURSIVE thread AS ( SELECT p.* FROM posts p - WHERE p.id = $1 AND p.parent_id IS NULL + WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL UNION ALL @@ -35,8 +46,10 @@ ORDER BY thread.created_at, thread.id; -- name: UpdatePost :execrows UPDATE posts -SET body = $2, updated_at = $3 -WHERE id = $1; +SET + body = sqlc.arg(body), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id); -- name: ListRootPosts :many WITH RECURSIVE roots AS ( @@ -47,8 +60,9 @@ WITH RECURSIVE roots AS ( AND p.hidden = 0 ), thread AS ( - SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id + SELECT roots.id AS root_id, child.id AS post_id, child.author_id FROM roots + JOIN posts child ON child.parent_id = roots.id UNION ALL @@ -57,15 +71,16 @@ thread AS ( JOIN posts child ON child.parent_id = thread.post_id ), answered AS ( - SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered + SELECT DISTINCT thread.root_id FROM thread JOIN users u ON u.id = thread.author_id - GROUP BY thread.root_id + WHERE u.role = 'admin' ), scores AS ( - SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score - FROM post_votes - GROUP BY post_id + SELECT votes.post_id, SUM(votes.value)::bigint AS score + FROM roots + JOIN post_votes votes ON votes.post_id = roots.id + GROUP BY votes.post_id ) SELECT roots.id, roots.parent_id, roots.author_id, @@ -73,17 +88,15 @@ SELECT roots.title, roots.body, roots.city, roots.post_date, roots.hidden, roots.created_at, roots.updated_at, COALESCE(scores.score, 0)::bigint AS score, - COALESCE(answered.answered, false)::bool AS answered, - COALESCE(( - SELECT post_votes.value - FROM post_votes - WHERE post_votes.user_id = sqlc.arg(viewer_id) - AND post_votes.post_id = roots.id - ), 0)::bigint AS user_vote + (answered.root_id IS NOT NULL)::bool AS answered, + COALESCE(viewer_vote.value, 0)::bigint AS user_vote FROM roots JOIN users u ON u.id = roots.author_id LEFT JOIN scores ON scores.post_id = roots.id LEFT JOIN answered ON answered.root_id = roots.id +LEFT JOIN post_votes viewer_vote + ON viewer_vote.user_id = sqlc.arg(viewer_id) + AND viewer_vote.post_id = roots.id ORDER BY score DESC, roots.created_at, roots.id LIMIT sqlc.arg(row_limit); @@ -91,22 +104,20 @@ LIMIT sqlc.arg(row_limit); SELECT EXISTS( SELECT 1 FROM posts - WHERE id = $1 AND parent_id IS NULL AND hidden = 0 + WHERE id = sqlc.arg(id) AND parent_id IS NULL AND hidden = 0 )::bool; -- name: DeletePostVote :exec DELETE FROM post_votes -WHERE user_id = $1 AND post_id = $2; +WHERE user_id = sqlc.arg(user_id) + AND post_id = sqlc.arg(post_id); -- name: UpsertPostVoteOnVisibleRoot :execrows INSERT INTO post_votes (user_id, post_id, value) -SELECT $1, $2, $3 +SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value) FROM posts p -WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 +WHERE p.id = sqlc.arg(post_id) + AND p.parent_id IS NULL + AND p.hidden = 0 ON CONFLICT (user_id, post_id) DO UPDATE -SET value = excluded.value -WHERE EXISTS ( - SELECT 1 - FROM posts p2 - WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0 -); +SET value = excluded.value; diff --git a/internal/store/migrate.go b/internal/store/migrate.go index fd5c10b..cc80253 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -104,6 +104,55 @@ ON CONFLICT (user_id, post_id) DO NOTHING`}, return nil } +func migratePostVoteIndex(ctx context.Context, exec execContext) error { + if _, err := exec.ExecContext(ctx, ` +CREATE INDEX IF NOT EXISTS idx_post_votes_post_id + ON post_votes(post_id)`); err != nil { + return fmt.Errorf("idx_post_votes_post_id: %w", err) + } + return nil +} + +func migratePostDate(ctx context.Context, exec execContext) error { + steps := []struct { + name string + sql string + }{ + {"rename post date", ` +DO $migration$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'posts' + AND column_name = 'hunt_date' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'posts' + AND column_name = 'post_date' + ) THEN + ALTER TABLE posts RENAME COLUMN hunt_date TO post_date; + END IF; +END +$migration$`}, + {"drop legacy root date index", ` +DROP INDEX IF EXISTS idx_posts_root_hunt`}, + {"create root date index", ` +CREATE INDEX IF NOT EXISTS idx_posts_root_date + ON posts(post_date, hidden) + WHERE parent_id IS NULL`}, + } + for _, step := range steps { + if _, err := exec.ExecContext(ctx, step.sql); err != nil { + return fmt.Errorf("%s: %w", step.name, err) + } + } + return nil +} + type execContext interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) @@ -151,6 +200,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( {"002_user_profile_columns", migrateUserProfileColumns}, {"003_user_email", migrateUserEmail}, {"004_posts", migratePosts}, + {"005_post_vote_post_id_index", migratePostVoteIndex}, + {"006_post_date", migratePostDate}, } for _, m := range migrations { if applied[m.version] { diff --git a/internal/store/migrate_posts_test.go b/internal/store/migrate_posts_test.go index b4975d2..ff23031 100644 --- a/internal/store/migrate_posts_test.go +++ b/internal/store/migrate_posts_test.go @@ -97,6 +97,12 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil { if err := migratePosts(ctx, conn); err != nil { t.Fatalf("migration is not idempotent: %v", err) } + if err := migratePostVoteIndex(ctx, conn); err != nil { + t.Fatal(err) + } + if err := migratePostVoteIndex(ctx, conn); err != nil { + t.Fatalf("post vote index migration is not idempotent: %v", err) + } var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil { @@ -120,6 +126,23 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil { legacyAnswerCount, ) } + var postVoteIndexCount int + if err := conn.QueryRowContext(ctx, ` +SELECT count(*) +FROM pg_indexes +WHERE schemaname = current_schema() + AND tablename = 'post_votes' + AND indexname = 'idx_post_votes_post_id'`).Scan(&postVoteIndexCount); err != nil { + t.Fatal(err) + } + if postVoteIndexCount != 1 { + t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount) + } + if _, err := conn.ExecContext(ctx, ` +INSERT INTO post_votes (user_id, post_id, value) +VALUES ('homeowner', 'question-1', -1)`); err == nil { + t.Fatal("duplicate user/post vote unexpectedly succeeded") + } var rootParent sql.NullString var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string @@ -210,6 +233,13 @@ INSERT INTO posts ( thread[2].ID != "follow-up" { t.Fatalf("recursive thread = %+v", thread) } + nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1") + if err != nil { + t.Fatal(err) + } + if len(nonRootThread) != 0 { + t.Fatalf("non-root thread lookup returned %+v", nonRootThread) + } if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{ ID: "follow-up", Body: "The drip continues.", @@ -238,6 +268,60 @@ INSERT INTO posts ( roots[0].UserVote != 1 { t.Fatalf("root annotations = %+v", roots) } + + if _, err := conn.ExecContext(ctx, ` +DROP INDEX idx_posts_root_date; +ALTER TABLE posts RENAME COLUMN post_date TO hunt_date; +CREATE INDEX idx_posts_root_hunt + ON posts(hunt_date, hidden) + WHERE parent_id IS NULL;`); err != nil { + t.Fatal(err) + } + if err := migratePostDate(ctx, conn); err != nil { + t.Fatal(err) + } + if err := migratePostDate(ctx, conn); err != nil { + t.Fatalf("post date migration is not idempotent: %v", err) + } + + var postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount int + if err := conn.QueryRowContext(ctx, ` +SELECT + count(*) FILTER (WHERE column_name = 'post_date'), + count(*) FILTER (WHERE column_name = 'hunt_date') +FROM information_schema.columns +WHERE table_schema = current_schema() + AND table_name = 'posts'`).Scan(&postDateColumnCount, &huntDateColumnCount); err != nil { + t.Fatal(err) + } + if err := conn.QueryRowContext(ctx, ` +SELECT + count(*) FILTER (WHERE indexname = 'idx_posts_root_date'), + count(*) FILTER (WHERE indexname = 'idx_posts_root_hunt') +FROM pg_indexes +WHERE schemaname = current_schema() + AND tablename = 'posts'`).Scan(&rootDateIndexCount, &legacyIndexCount); err != nil { + t.Fatal(err) + } + var migratedPostDate string + if err := conn.QueryRowContext(ctx, ` +SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil { + t.Fatal(err) + } + if postDateColumnCount != 1 || + huntDateColumnCount != 0 || + rootDateIndexCount != 1 || + legacyIndexCount != 0 || + migratedPostDate != "2026-08-26" { + t.Fatalf( + "post date migration columns=%d legacy_columns=%d indexes=%d legacy_indexes=%d date=%q", + postDateColumnCount, + huntDateColumnCount, + rootDateIndexCount, + legacyIndexCount, + migratedPostDate, + ) + } } func TestMigratePostsReportsStep(t *testing.T) { diff --git a/internal/store/sqlc/posts.sql.go b/internal/store/sqlc/posts.sql.go index c0f647a..eaf7bca 100644 --- a/internal/store/sqlc/posts.sql.go +++ b/internal/store/sqlc/posts.sql.go @@ -14,7 +14,18 @@ const createPost = `-- name: CreatePost :exec INSERT INTO posts ( id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10 +) ` type CreatePostParams struct { @@ -48,7 +59,8 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error { const deletePostVote = `-- name: DeletePostVote :exec DELETE FROM post_votes -WHERE user_id = $1 AND post_id = $2 +WHERE user_id = $1 + AND post_id = $2 ` type DeletePostVoteParams struct { @@ -142,8 +154,8 @@ type ListPostThreadRow struct { UpdatedAt string } -func (q *Queries) ListPostThread(ctx context.Context, id string) ([]ListPostThreadRow, error) { - rows, err := q.db.QueryContext(ctx, listPostThread, id) +func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPostThreadRow, error) { + rows, err := q.db.QueryContext(ctx, listPostThread, rootID) if err != nil { return nil, err } @@ -187,8 +199,9 @@ WITH RECURSIVE roots AS ( AND p.hidden = 0 ), thread AS ( - SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id + SELECT roots.id AS root_id, child.id AS post_id, child.author_id FROM roots + JOIN posts child ON child.parent_id = roots.id UNION ALL @@ -197,15 +210,16 @@ thread AS ( JOIN posts child ON child.parent_id = thread.post_id ), answered AS ( - SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered + SELECT DISTINCT thread.root_id FROM thread JOIN users u ON u.id = thread.author_id - GROUP BY thread.root_id + WHERE u.role = 'admin' ), scores AS ( - SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score - FROM post_votes - GROUP BY post_id + SELECT votes.post_id, SUM(votes.value)::bigint AS score + FROM roots + JOIN post_votes votes ON votes.post_id = roots.id + GROUP BY votes.post_id ) SELECT roots.id, roots.parent_id, roots.author_id, @@ -213,17 +227,15 @@ SELECT roots.title, roots.body, roots.city, roots.post_date, roots.hidden, roots.created_at, roots.updated_at, COALESCE(scores.score, 0)::bigint AS score, - COALESCE(answered.answered, false)::bool AS answered, - COALESCE(( - SELECT post_votes.value - FROM post_votes - WHERE post_votes.user_id = $1 - AND post_votes.post_id = roots.id - ), 0)::bigint AS user_vote + (answered.root_id IS NOT NULL)::bool AS answered, + COALESCE(viewer_vote.value, 0)::bigint AS user_vote FROM roots JOIN users u ON u.id = roots.author_id LEFT JOIN scores ON scores.post_id = roots.id LEFT JOIN answered ON answered.root_id = roots.id +LEFT JOIN post_votes viewer_vote + ON viewer_vote.user_id = $1 + AND viewer_vote.post_id = roots.id ORDER BY score DESC, roots.created_at, roots.id LIMIT $2 ` @@ -308,18 +320,20 @@ func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error const updatePost = `-- name: UpdatePost :execrows UPDATE posts -SET body = $2, updated_at = $3 -WHERE id = $1 +SET + body = $1, + updated_at = $2 +WHERE id = $3 ` type UpdatePostParams struct { - ID string Body string UpdatedAt string + ID string } func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updatePost, arg.ID, arg.Body, arg.UpdatedAt) + result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID) if err != nil { return 0, err } @@ -330,14 +344,11 @@ const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execr INSERT INTO post_votes (user_id, post_id, value) SELECT $1, $2, $3 FROM posts p -WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 +WHERE p.id = $2 + AND p.parent_id IS NULL + AND p.hidden = 0 ON CONFLICT (user_id, post_id) DO UPDATE SET value = excluded.value -WHERE EXISTS ( - SELECT 1 - FROM posts p2 - WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0 -) ` type UpsertPostVoteOnVisibleRootParams struct { diff --git a/schema.sql b/schema.sql index 5571cad..ff92e94 100644 --- a/schema.sql +++ b/schema.sql @@ -74,6 +74,9 @@ CREATE TABLE IF NOT EXISTS post_votes ( PRIMARY KEY (user_id, post_id) ); +CREATE INDEX IF NOT EXISTS idx_post_votes_post_id + ON post_votes(post_id); + CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, data BYTEA NOT NULL, -- 2.43.0 From b481dd2925ad0e7ec561699941c54e369c52036e Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 00:37:17 -0700 Subject: [PATCH 3/3] Replace hidden flag with post state. Store post state as text so Go owns the allowed values and future states such as locked remain representable without a database enum migration. --- db/queries/posts.sql | 18 ++-- internal/store/memory.go | 4 +- internal/store/migrate.go | 144 +++++++++++++++++++++++---- internal/store/migrate_posts_test.go | 112 ++++++++++++++++++--- internal/store/post.go | 58 +++++++---- internal/store/post_test.go | 55 ++++++++-- internal/store/sqlc/models.go | 2 +- internal/store/sqlc/posts.sql.go | 75 ++++++++------ schema.sql | 8 +- 9 files changed, 372 insertions(+), 104 deletions(-) diff --git a/db/queries/posts.sql b/db/queries/posts.sql index 626e109..2d7361a 100644 --- a/db/queries/posts.sql +++ b/db/queries/posts.sql @@ -1,6 +1,6 @@ -- name: CreatePost :exec INSERT INTO posts ( - id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at ) VALUES ( sqlc.arg(id), @@ -10,7 +10,7 @@ VALUES ( sqlc.arg(body), sqlc.arg(city), sqlc.arg(post_date), - sqlc.arg(hidden), + sqlc.arg(post_state), sqlc.arg(created_at), sqlc.arg(updated_at) ); @@ -18,7 +18,7 @@ VALUES ( -- name: GetPost :one SELECT p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, - p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at FROM posts p JOIN users u ON u.id = p.author_id WHERE p.id = sqlc.arg(id); @@ -39,7 +39,7 @@ SELECT thread.id, thread.parent_id, thread.author_id, u.name AS author_name, u.role AS author_role, thread.title, thread.body, thread.city, thread.post_date, - thread.hidden, thread.created_at, thread.updated_at + thread.post_state, thread.created_at, thread.updated_at FROM thread JOIN users u ON u.id = thread.author_id ORDER BY thread.created_at, thread.id; @@ -57,7 +57,7 @@ WITH RECURSIVE roots AS ( FROM posts p WHERE p.parent_id IS NULL AND p.post_date = sqlc.arg(post_date) - AND p.hidden = 0 + AND p.post_state <> sqlc.arg(hidden_state) ), thread AS ( SELECT roots.id AS root_id, child.id AS post_id, child.author_id @@ -86,7 +86,7 @@ SELECT roots.id, roots.parent_id, roots.author_id, u.name AS author_name, u.role AS author_role, roots.title, roots.body, roots.city, roots.post_date, - roots.hidden, roots.created_at, roots.updated_at, + roots.post_state, roots.created_at, roots.updated_at, COALESCE(scores.score, 0)::bigint AS score, (answered.root_id IS NOT NULL)::bool AS answered, COALESCE(viewer_vote.value, 0)::bigint AS user_vote @@ -104,7 +104,9 @@ LIMIT sqlc.arg(row_limit); SELECT EXISTS( SELECT 1 FROM posts - WHERE id = sqlc.arg(id) AND parent_id IS NULL AND hidden = 0 + WHERE id = sqlc.arg(id) + AND parent_id IS NULL + AND post_state <> sqlc.arg(hidden_state) )::bool; -- name: DeletePostVote :exec @@ -118,6 +120,6 @@ SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value) FROM posts p WHERE p.id = sqlc.arg(post_id) AND p.parent_id IS NULL - AND p.hidden = 0 + AND p.post_state <> sqlc.arg(hidden_state) ON CONFLICT (user_id, post_id) DO UPDATE SET value = excluded.value; diff --git a/internal/store/memory.go b/internal/store/memory.go index ef615f5..00d3c6f 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -463,7 +463,7 @@ func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([] defer m.mu.Unlock() posts := make([]Post, 0) for _, post := range m.posts { - if post.ParentID != nil || post.PostDate != postDate || post.Hidden { + if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden { continue } cp := clonePostWithAuthor(post, m.users) @@ -496,7 +496,7 @@ func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) e return fmt.Errorf("invalid vote") } post, ok := m.posts[postID] - if !ok || post.ParentID != nil || post.Hidden { + if !ok || post.ParentID != nil || post.PostState == PostStateHidden { return ErrPostNotVotable } if m.postVotes[postID] == nil { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index cc80253..6783e06 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -41,8 +41,9 @@ func migratePosts(ctx context.Context, exec execContext) error { steps := []struct { name string sql string + args []any }{ - {"create posts", ` + {name: "create posts", sql: ` CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE, @@ -51,53 +52,56 @@ CREATE TABLE IF NOT EXISTS posts ( body TEXT NOT NULL, city TEXT NOT NULL DEFAULT '', post_date TEXT NOT NULL DEFAULT '', - hidden INTEGER NOT NULL DEFAULT 0, + post_state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK ( + CONSTRAINT posts_shape_check CHECK ( (parent_id IS NULL AND title <> '' AND post_date <> '') OR - (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0) + (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '') ) )`}, - {"index post replies", ` + {name: "index post replies", sql: ` CREATE INDEX IF NOT EXISTS idx_posts_parent_created ON posts(parent_id, created_at, id)`}, - {"index root posts", ` + {name: "index root posts", sql: ` CREATE INDEX IF NOT EXISTS idx_posts_root_date - ON posts(post_date, hidden) + ON posts(post_date, post_state) WHERE parent_id IS NULL`}, - {"create post votes", ` + {name: "create post votes", sql: ` CREATE TABLE IF NOT EXISTS post_votes ( user_id TEXT NOT NULL REFERENCES users(id), post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK (value IN (-1, 1)), PRIMARY KEY (user_id, post_id) )`}, - {"copy questions", ` + {name: "copy questions", sql: ` INSERT INTO posts ( - id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at ) SELECT - id, NULL, author_id, title, body, city, hunt_date, hidden, created_at, created_at + id, NULL, author_id, title, body, city, hunt_date, + CASE WHEN hidden = 0 THEN $1 ELSE $2 END, + created_at, created_at FROM questions -ON CONFLICT (id) DO NOTHING`}, - {"copy answers", ` +ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}}, + {name: "copy answers", sql: ` INSERT INTO posts ( - id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at ) SELECT - 'answer:' || question_id, question_id, author_id, '', body, '', '', 0, created_at, updated_at + 'answer:' || question_id, question_id, author_id, '', body, '', '', + $1, created_at, updated_at FROM answers -ON CONFLICT (id) DO NOTHING`}, - {"copy votes", ` +ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}}, + {name: "copy votes", sql: ` INSERT INTO post_votes (user_id, post_id, value) SELECT user_id, question_id, value FROM votes ON CONFLICT (user_id, post_id) DO NOTHING`}, } for _, step := range steps { - if _, err := exec.ExecContext(ctx, step.sql); err != nil { + if _, err := exec.ExecContext(ctx, step.sql, step.args...); err != nil { return fmt.Errorf("%s: %w", step.name, err) } } @@ -141,8 +145,79 @@ $migration$`}, {"drop legacy root date index", ` DROP INDEX IF EXISTS idx_posts_root_hunt`}, {"create root date index", ` -CREATE INDEX IF NOT EXISTS idx_posts_root_date - ON posts(post_date, hidden) +DO $migration$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'posts' + AND column_name = 'post_state' + ) THEN + CREATE INDEX IF NOT EXISTS idx_posts_root_date + ON posts(post_date, post_state) + WHERE parent_id IS NULL; + ELSE + CREATE INDEX IF NOT EXISTS idx_posts_root_date + ON posts(post_date, hidden) + WHERE parent_id IS NULL; + END IF; +END +$migration$`}, + } + for _, step := range steps { + if _, err := exec.ExecContext(ctx, step.sql); err != nil { + return fmt.Errorf("%s: %w", step.name, err) + } + } + return nil +} + +func migratePostState(ctx context.Context, exec execContext) error { + if _, err := exec.ExecContext(ctx, ` +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS post_state TEXT`); err != nil { + return fmt.Errorf("add post state: %w", err) + } + hasHidden, err := migrationColumnExists(ctx, exec, "posts", "hidden") + if err != nil { + return fmt.Errorf("check hidden column: %w", err) + } + if hasHidden { + if _, err := exec.ExecContext(ctx, ` +UPDATE posts +SET post_state = CASE + WHEN hidden = 0 THEN $1 + ELSE $2 +END`, string(PostStateVisible), string(PostStateHidden)); err != nil { + return fmt.Errorf("copy hidden state: %w", err) + } + } + + steps := []struct { + name string + sql string + }{ + {"drop root date index", ` +DROP INDEX IF EXISTS idx_posts_root_date`}, + {"drop legacy post shape constraint", ` +ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_check`}, + {"drop post shape constraint", ` +ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_shape_check`}, + {"drop hidden", ` +ALTER TABLE posts DROP COLUMN IF EXISTS hidden`}, + {"require post state", ` +ALTER TABLE posts ALTER COLUMN post_state SET NOT NULL`}, + {"create post shape constraint", ` +ALTER TABLE posts + ADD CONSTRAINT posts_shape_check CHECK ( + (parent_id IS NULL AND title <> '' AND post_date <> '') + OR + (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '') + )`}, + {"create root date index", ` +CREATE INDEX idx_posts_root_date + ON posts(post_date, post_state) WHERE parent_id IS NULL`}, } for _, step := range steps { @@ -153,6 +228,34 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date return nil } +func migrationColumnExists( + ctx context.Context, + exec execContext, + tableName string, + columnName string, +) (bool, error) { + rows, err := exec.QueryContext(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = $1 + AND column_name = $2 +)`, tableName, columnName) + if err != nil { + return false, err + } + defer rows.Close() + if !rows.Next() { + return false, rows.Err() + } + var exists bool + if err := rows.Scan(&exists); err != nil { + return false, err + } + return exists, rows.Err() +} + type execContext interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) @@ -202,6 +305,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( {"004_posts", migratePosts}, {"005_post_vote_post_id_index", migratePostVoteIndex}, {"006_post_date", migratePostDate}, + {"007_post_state", migratePostState}, } for _, m := range migrations { if applied[m.version] { diff --git a/internal/store/migrate_posts_test.go b/internal/store/migrate_posts_test.go index ff23031..3f75643 100644 --- a/internal/store/migrate_posts_test.go +++ b/internal/store/migrate_posts_test.go @@ -145,9 +145,9 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil { } var rootParent sql.NullString - var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string + var rootAuthor, title, rootBody, city, postDate, rootState, rootCreated, rootUpdated string if err := conn.QueryRowContext(ctx, ` -SELECT parent_id, author_id, title, body, city, post_date, created_at, updated_at +SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at FROM posts WHERE id = 'question-1'`).Scan( &rootParent, @@ -156,6 +156,7 @@ WHERE id = 'question-1'`).Scan( &rootBody, &city, &postDate, + &rootState, &rootCreated, &rootUpdated, ); err != nil { @@ -167,19 +168,21 @@ WHERE id = 'question-1'`).Scan( rootBody != "It drips." || city != "Oakland" || postDate != "2026-08-26" || + rootState != "visible" || rootCreated != "2026-08-26T08:00:00Z" || rootUpdated != rootCreated { t.Fatalf("unexpected root post") } - var replyParent, replyAuthor, replyBody, replyCreated, replyUpdated string + var replyParent, replyAuthor, replyBody, replyState, replyCreated, replyUpdated string if err := conn.QueryRowContext(ctx, ` -SELECT parent_id, author_id, body, created_at, updated_at +SELECT parent_id, author_id, body, post_state, created_at, updated_at FROM posts WHERE id = 'answer:question-1'`).Scan( &replyParent, &replyAuthor, &replyBody, + &replyState, &replyCreated, &replyUpdated, ); err != nil { @@ -188,6 +191,7 @@ WHERE id = 'answer:question-1'`).Scan( if replyParent != "question-1" || replyAuthor != "plumber" || replyBody != "Replace the cartridge." || + replyState != "visible" || replyCreated != "2026-08-26T09:00:00Z" || replyUpdated != "2026-08-26T09:05:00Z" { t.Fatalf("unexpected reply post") @@ -205,9 +209,10 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err ! if _, err := conn.ExecContext(ctx, ` INSERT INTO posts ( - id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at ) VALUES ( - 'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 0, 'now', 'now' + 'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', + 'visible', 'now', 'now' )`); err == nil { t.Fatal("reply with root-only title unexpectedly succeeded") } @@ -218,6 +223,7 @@ INSERT INTO posts ( ParentID: sql.NullString{String: "answer:question-1", Valid: true}, AuthorID: "homeowner", Body: "It is still dripping.", + PostState: string(PostStateVisible), CreatedAt: "2026-08-26T10:00:00Z", UpdatedAt: "2026-08-26T10:00:00Z", }); err != nil { @@ -248,16 +254,18 @@ INSERT INTO posts ( t.Fatalf("update rows=%d error=%v", n, err) } if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ - UserID: "plumber", - PostID: "question-1", - Value: 1, + UserID: "plumber", + PostID: "question-1", + Value: 1, + HiddenState: string(PostStateHidden), }); err != nil || n != 1 { t.Fatalf("vote rows=%d error=%v", n, err) } roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{ - ViewerID: "plumber", - RowLimit: 100, - PostDate: "2026-08-26", + ViewerID: "plumber", + RowLimit: 100, + PostDate: "2026-08-26", + HiddenState: string(PostStateHidden), }) if err != nil { t.Fatal(err) @@ -273,7 +281,7 @@ INSERT INTO posts ( DROP INDEX idx_posts_root_date; ALTER TABLE posts RENAME COLUMN post_date TO hunt_date; CREATE INDEX idx_posts_root_hunt - ON posts(hunt_date, hidden) + ON posts(hunt_date, post_state) WHERE parent_id IS NULL;`); err != nil { t.Fatal(err) } @@ -322,6 +330,84 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e migratedPostDate, ) } + + if _, err := conn.ExecContext(ctx, ` +DROP INDEX idx_posts_root_date; +ALTER TABLE posts DROP CONSTRAINT posts_shape_check; +ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0; +UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END; +ALTER TABLE posts DROP COLUMN post_state; +ALTER TABLE posts ADD CONSTRAINT posts_check CHECK ( + (parent_id IS NULL AND title <> '' AND post_date <> '') + OR + (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0) +); +CREATE INDEX idx_posts_root_date + ON posts(post_date, hidden) + WHERE parent_id IS NULL;`); err != nil { + t.Fatal(err) + } + if err := migratePostState(ctx, conn); err != nil { + t.Fatal(err) + } + if err := migratePostState(ctx, conn); err != nil { + t.Fatalf("post state migration is not idempotent: %v", err) + } + + var postStateColumnCount, hiddenColumnCount int + if err := conn.QueryRowContext(ctx, ` +SELECT + count(*) FILTER (WHERE column_name = 'post_state'), + count(*) FILTER (WHERE column_name = 'hidden') +FROM information_schema.columns +WHERE table_schema = current_schema() + AND table_name = 'posts'`).Scan(&postStateColumnCount, &hiddenColumnCount); err != nil { + t.Fatal(err) + } + var hiddenState, replyStateAfterMigration string + if err := conn.QueryRowContext(ctx, ` +SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil { + t.Fatal(err) + } + if err := conn.QueryRowContext(ctx, ` +SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil { + t.Fatal(err) + } + var postStateDataType string + if err := conn.QueryRowContext(ctx, ` +SELECT data_type +FROM information_schema.columns +WHERE table_schema = current_schema() + AND table_name = 'posts' + AND column_name = 'post_state'`).Scan(&postStateDataType); err != nil { + t.Fatal(err) + } + var stateIndexCount int + if err := conn.QueryRowContext(ctx, ` +SELECT count(*) +FROM pg_indexes +WHERE schemaname = current_schema() + AND tablename = 'posts' + AND indexname = 'idx_posts_root_date' + AND indexdef LIKE '%(post_date, post_state)%'`).Scan(&stateIndexCount); err != nil { + t.Fatal(err) + } + if postStateColumnCount != 1 || + hiddenColumnCount != 0 || + hiddenState != "hidden" || + replyStateAfterMigration != "visible" || + postStateDataType != "text" || + stateIndexCount != 1 { + t.Fatalf( + "post state migration columns=%d hidden_columns=%d root=%q reply=%q type=%q indexes=%d", + postStateColumnCount, + hiddenColumnCount, + hiddenState, + replyStateAfterMigration, + postStateDataType, + stateIndexCount, + ) + } } func TestMigratePostsReportsStep(t *testing.T) { diff --git a/internal/store/post.go b/internal/store/post.go index d7c107d..9961e85 100644 --- a/internal/store/post.go +++ b/internal/store/post.go @@ -21,6 +21,14 @@ var ( 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 @@ -32,7 +40,7 @@ type Post struct { Body string City string PostDate string - Hidden bool + PostState PostState CreatedAt string UpdatedAt string Score int @@ -63,7 +71,7 @@ func (p *Post) Create(ctx context.Context) error { Body: p.Body, City: p.City, PostDate: p.PostDate, - Hidden: boolInt32(p.Hidden), + PostState: string(p.PostState), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, }) @@ -101,6 +109,14 @@ func preparePost(p *Post) error { 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) } @@ -120,7 +136,7 @@ func preparePost(p *Post) error { return fmt.Errorf("%w: parent is required", ErrInvalidPost) } p.ParentID = &parentID - if p.Title != "" || p.City != "" || p.PostDate != "" || p.Hidden { + if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible { return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost) } } @@ -152,13 +168,6 @@ func parentIDFromNull(parentID sql.NullString) *string { return &id } -func boolInt32(value bool) int32 { - if value { - return 1 - } - return 0 -} - func mapPostCreateError(err error) error { if err == nil { return nil @@ -178,7 +187,7 @@ func postFromValues( id string, parentID sql.NullString, authorID, authorName, authorRole, title, body, city, postDate string, - hidden int32, + postState string, createdAt, updatedAt string, ) Post { return Post{ @@ -191,7 +200,7 @@ func postFromValues( Body: body, City: city, PostDate: postDate, - Hidden: hidden != 0, + PostState: PostState(postState), CreatedAt: createdAt, UpdatedAt: updatedAt, db: db, @@ -215,7 +224,7 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) { r.Body, r.City, r.PostDate, - r.Hidden, + r.PostState, r.CreatedAt, r.UpdatedAt, ) @@ -241,7 +250,7 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error r.Body, r.City, r.PostDate, - r.Hidden, + r.PostState, r.CreatedAt, r.UpdatedAt, )) @@ -292,9 +301,10 @@ func buildPostTree(posts []Post, rootID string) (*Post, error) { // 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, + ViewerID: viewerID, + RowLimit: HuntListLimit, + PostDate: postDate, + HiddenState: string(PostStateHidden), }) if err != nil { return nil, err @@ -312,7 +322,7 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ( r.Body, r.City, r.PostDate, - r.Hidden, + r.PostState, r.CreatedAt, r.UpdatedAt, ) @@ -331,7 +341,10 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i } q := sqlc.New(db) if value == 0 { - visible, err := q.PostIsVisibleRoot(ctx, postID) + visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{ + ID: postID, + HiddenState: string(PostStateHidden), + }) if err != nil { return err } @@ -344,9 +357,10 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i }) } n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ - UserID: userID, - PostID: postID, - Value: int32(value), + UserID: userID, + PostID: postID, + Value: int32(value), + HiddenState: string(PostStateHidden), }) if err != nil { return err diff --git a/internal/store/post_test.go b/internal/store/post_test.go index a3e66b8..13f80c5 100644 --- a/internal/store/post_test.go +++ b/internal/store/post_test.go @@ -50,6 +50,9 @@ func TestMemoryPostLifecycle(t *testing.T) { if err := mem.CreatePost(ctx, root); err != nil { t.Fatal(err) } + if root.PostState != PostStateVisible { + t.Fatalf("default post state = %q, want visible", root.PostState) + } rootID := root.ID later := &Post{ @@ -175,6 +178,24 @@ func TestMemoryPostValidation(t *testing.T) { Body: "Body", }, }, + { + name: "invalid post state", + post: &Post{ + AuthorID: homeowner.ID, + Title: "Invalid state", + Body: "Body", + PostState: PostState("archived"), + }, + }, + { + name: "reply with non-visible state", + post: &Post{ + ParentID: ptr("missing"), + AuthorID: homeowner.ID, + Body: "Body", + PostState: PostStateLocked, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -185,12 +206,12 @@ func TestMemoryPostValidation(t *testing.T) { } hidden := &Post{ - ID: "hidden", - AuthorID: homeowner.ID, - Title: "Hidden", - Body: "Body", - PostDate: "2026-08-26", - Hidden: true, + ID: "hidden", + AuthorID: homeowner.ID, + Title: "Hidden", + Body: "Body", + PostDate: "2026-08-26", + PostState: PostStateHidden, } if err := mem.CreatePost(ctx, hidden); err != nil { t.Fatal(err) @@ -199,6 +220,28 @@ func TestMemoryPostValidation(t *testing.T) { t.Fatalf("hidden root vote error = %v", err) } + locked := &Post{ + ID: "locked", + AuthorID: homeowner.ID, + Title: "Locked", + Body: "Body", + PostDate: "2026-08-26", + PostState: PostStateLocked, + } + if err := mem.CreatePost(ctx, locked); err != nil { + t.Fatal(err) + } + if err := mem.VotePost(ctx, homeowner.ID, locked.ID, 1); err != nil { + t.Fatalf("locked root should remain votable: %v", err) + } + roots, err := mem.ListRootPosts(ctx, locked.PostDate, homeowner.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 1 || roots[0].ID != locked.ID || roots[0].PostState != PostStateLocked { + t.Fatalf("locked root should remain visible: %+v", roots) + } + if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("missing thread error = %v", err) } diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index f2b2acc..35568ed 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -25,7 +25,7 @@ type Post struct { Body string City string PostDate string - Hidden int32 + PostState string CreatedAt string UpdatedAt string } diff --git a/internal/store/sqlc/posts.sql.go b/internal/store/sqlc/posts.sql.go index eaf7bca..97353f2 100644 --- a/internal/store/sqlc/posts.sql.go +++ b/internal/store/sqlc/posts.sql.go @@ -12,7 +12,7 @@ import ( const createPost = `-- name: CreatePost :exec INSERT INTO posts ( - id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at ) VALUES ( $1, @@ -36,7 +36,7 @@ type CreatePostParams struct { Body string City string PostDate string - Hidden int32 + PostState string CreatedAt string UpdatedAt string } @@ -50,7 +50,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error { arg.Body, arg.City, arg.PostDate, - arg.Hidden, + arg.PostState, arg.CreatedAt, arg.UpdatedAt, ) @@ -76,7 +76,7 @@ func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams) const getPost = `-- name: GetPost :one SELECT p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, - p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at FROM posts p JOIN users u ON u.id = p.author_id WHERE p.id = $1 @@ -92,7 +92,7 @@ type GetPostRow struct { Body string City string PostDate string - Hidden int32 + PostState string CreatedAt string UpdatedAt string } @@ -110,7 +110,7 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) { &i.Body, &i.City, &i.PostDate, - &i.Hidden, + &i.PostState, &i.CreatedAt, &i.UpdatedAt, ) @@ -119,13 +119,13 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) { const listPostThread = `-- name: ListPostThread :many WITH RECURSIVE thread AS ( - SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at FROM posts p WHERE p.id = $1 AND p.parent_id IS NULL UNION ALL - SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.hidden, child.created_at, child.updated_at + SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.post_state, child.created_at, child.updated_at FROM posts child JOIN thread parent ON child.parent_id = parent.id ) @@ -133,7 +133,7 @@ SELECT thread.id, thread.parent_id, thread.author_id, u.name AS author_name, u.role AS author_role, thread.title, thread.body, thread.city, thread.post_date, - thread.hidden, thread.created_at, thread.updated_at + thread.post_state, thread.created_at, thread.updated_at FROM thread JOIN users u ON u.id = thread.author_id ORDER BY thread.created_at, thread.id @@ -149,7 +149,7 @@ type ListPostThreadRow struct { Body string City string PostDate string - Hidden int32 + PostState string CreatedAt string UpdatedAt string } @@ -173,7 +173,7 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost &i.Body, &i.City, &i.PostDate, - &i.Hidden, + &i.PostState, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -192,11 +192,11 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost const listRootPosts = `-- name: ListRootPosts :many WITH RECURSIVE roots AS ( - SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at + SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at FROM posts p WHERE p.parent_id IS NULL AND p.post_date = $3 - AND p.hidden = 0 + AND p.post_state <> $4 ), thread AS ( SELECT roots.id AS root_id, child.id AS post_id, child.author_id @@ -225,7 +225,7 @@ SELECT roots.id, roots.parent_id, roots.author_id, u.name AS author_name, u.role AS author_role, roots.title, roots.body, roots.city, roots.post_date, - roots.hidden, roots.created_at, roots.updated_at, + roots.post_state, roots.created_at, roots.updated_at, COALESCE(scores.score, 0)::bigint AS score, (answered.root_id IS NOT NULL)::bool AS answered, COALESCE(viewer_vote.value, 0)::bigint AS user_vote @@ -241,9 +241,10 @@ LIMIT $2 ` type ListRootPostsParams struct { - ViewerID string - RowLimit int32 - PostDate string + ViewerID string + RowLimit int32 + PostDate string + HiddenState string } type ListRootPostsRow struct { @@ -256,7 +257,7 @@ type ListRootPostsRow struct { Body string City string PostDate string - Hidden int32 + PostState string CreatedAt string UpdatedAt string Score int64 @@ -265,7 +266,12 @@ type ListRootPostsRow struct { } func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) { - rows, err := q.db.QueryContext(ctx, listRootPosts, arg.ViewerID, arg.RowLimit, arg.PostDate) + rows, err := q.db.QueryContext(ctx, listRootPosts, + arg.ViewerID, + arg.RowLimit, + arg.PostDate, + arg.HiddenState, + ) if err != nil { return nil, err } @@ -283,7 +289,7 @@ func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([ &i.Body, &i.City, &i.PostDate, - &i.Hidden, + &i.PostState, &i.CreatedAt, &i.UpdatedAt, &i.Score, @@ -307,12 +313,19 @@ const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one SELECT EXISTS( SELECT 1 FROM posts - WHERE id = $1 AND parent_id IS NULL AND hidden = 0 + WHERE id = $1 + AND parent_id IS NULL + AND post_state <> $2 )::bool ` -func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error) { - row := q.db.QueryRowContext(ctx, postIsVisibleRoot, id) +type PostIsVisibleRootParams struct { + ID string + HiddenState string +} + +func (q *Queries) PostIsVisibleRoot(ctx context.Context, arg PostIsVisibleRootParams) (bool, error) { + row := q.db.QueryRowContext(ctx, postIsVisibleRoot, arg.ID, arg.HiddenState) var column_1 bool err := row.Scan(&column_1) return column_1, err @@ -346,19 +359,25 @@ SELECT $1, $2, $3 FROM posts p WHERE p.id = $2 AND p.parent_id IS NULL - AND p.hidden = 0 + AND p.post_state <> $4 ON CONFLICT (user_id, post_id) DO UPDATE SET value = excluded.value ` type UpsertPostVoteOnVisibleRootParams struct { - UserID string - PostID string - Value int32 + UserID string + PostID string + Value int32 + HiddenState string } func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) { - result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot, arg.UserID, arg.PostID, arg.Value) + result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot, + arg.UserID, + arg.PostID, + arg.Value, + arg.HiddenState, + ) if err != nil { return 0, err } diff --git a/schema.sql b/schema.sql index ff92e94..1b54523 100644 --- a/schema.sql +++ b/schema.sql @@ -50,13 +50,13 @@ CREATE TABLE IF NOT EXISTS posts ( body TEXT NOT NULL, city TEXT NOT NULL DEFAULT '', post_date TEXT NOT NULL DEFAULT '', - hidden INTEGER NOT NULL DEFAULT 0, + post_state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK ( + CONSTRAINT posts_shape_check CHECK ( (parent_id IS NULL AND title <> '' AND post_date <> '') OR - (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0) + (parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '') ) ); @@ -64,7 +64,7 @@ CREATE INDEX IF NOT EXISTS idx_posts_parent_created ON posts(parent_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_posts_root_date - ON posts(post_date, hidden) + ON posts(post_date, post_state) WHERE parent_id IS NULL; CREATE TABLE IF NOT EXISTS post_votes ( -- 2.43.0