From e86c2072eab5e90ce141d897f936d4c57d769618 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 07:43:25 +0000 Subject: [PATCH] Add unified post storage (#3) Adds one post creation path for roots and replies, recursive thread loading, body-only updates, root listings, and root-only voting across PostgreSQL and the in-memory store. Co-authored-by: codegirl-007 --- db/queries/posts.sql | 125 +++++++++ internal/store/memory.go | 184 +++++++++++++ internal/store/migrate.go | 189 +++++++++++-- internal/store/migrate_posts_test.go | 241 ++++++++++++++++- internal/store/post.go | 372 ++++++++++++++++++++++++++ internal/store/post_test.go | 252 ++++++++++++++++++ internal/store/postgres_store.go | 26 ++ internal/store/sqlc/models.go | 2 +- internal/store/sqlc/posts.sql.go | 385 +++++++++++++++++++++++++++ internal/store/store.go | 7 + schema.sql | 11 +- 11 files changed, 1764 insertions(+), 30 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..2d7361a --- /dev/null +++ b/db/queries/posts.sql @@ -0,0 +1,125 @@ +-- name: CreatePost :exec +INSERT INTO posts ( + id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at +) +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(post_state), + sqlc.arg(created_at), + sqlc.arg(updated_at) +); + +-- 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.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); + +-- name: ListPostThread :many +WITH RECURSIVE thread AS ( + SELECT p.* + FROM posts p + WHERE p.id = sqlc.arg(root_id) 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.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; + +-- name: UpdatePost :execrows +UPDATE posts +SET + body = sqlc.arg(body), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id); + +-- 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.post_state <> sqlc.arg(hidden_state) +), +thread AS ( + 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 + + SELECT thread.root_id, child.id, child.author_id + FROM thread + JOIN posts child ON child.parent_id = thread.post_id +), +answered AS ( + SELECT DISTINCT thread.root_id + FROM thread + JOIN users u ON u.id = thread.author_id + WHERE u.role = 'admin' +), +scores AS ( + 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, + u.name AS author_name, u.role AS author_role, + roots.title, roots.body, roots.city, roots.post_date, + 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 +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); + +-- name: PostIsVisibleRoot :one +SELECT EXISTS( + SELECT 1 + FROM posts + WHERE id = sqlc.arg(id) + AND parent_id IS NULL + AND post_state <> sqlc.arg(hidden_state) +)::bool; + +-- name: DeletePostVote :exec +DELETE FROM post_votes +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 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.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 60e3170..00d3c6f 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.PostState == PostStateHidden { + 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.PostState == PostStateHidden { + 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.go b/internal/store/migrate.go index fd5c10b..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,50 +52,118 @@ 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, step.args...); err != nil { + return fmt.Errorf("%s: %w", step.name, err) + } + } + 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", ` +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 { @@ -104,6 +173,89 @@ ON CONFLICT (user_id, post_id) DO NOTHING`}, 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 { + if _, err := exec.ExecContext(ctx, step.sql); err != nil { + return fmt.Errorf("%s: %w", step.name, err) + } + } + 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) @@ -151,6 +303,9 @@ 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}, + {"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 6a81d37..3f75643 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) @@ -92,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 { @@ -115,11 +126,28 @@ 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 + 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, @@ -128,6 +156,7 @@ WHERE id = 'question-1'`).Scan( &rootBody, &city, &postDate, + &rootState, &rootCreated, &rootUpdated, ); err != nil { @@ -139,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 { @@ -160,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") @@ -177,12 +209,205 @@ 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") } + + 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.", + PostState: string(PostStateVisible), + 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) + } + 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.", + 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, + 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", + HiddenState: string(PostStateHidden), + }) + 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) + } + + 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, post_state) + 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, + ) + } + + 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 new file mode 100644 index 0000000..9961e85 --- /dev/null +++ b/internal/store/post.go @@ -0,0 +1,372 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "plumber/internal/pacific" + "plumber/internal/store/sqlc" +) + +var ( + ErrInvalidPost = errors.New("invalid post") + ErrPostNotVotable = errors.New("post not votable") +) + +type PostState string + +const ( + PostStateVisible PostState = "visible" + PostStateHidden PostState = "hidden" + PostStateLocked PostState = "locked" +) + +// Post is either a root question (ParentID nil) or a reply to another post. +type Post struct { + ID string + ParentID *string + AuthorID string + AuthorName string + AuthorRole Role + Title string + Body string + City string + PostDate string + PostState PostState + CreatedAt string + UpdatedAt string + Score int + Answered bool + UserVote int + Replies []*Post + db *sql.DB +} + +// NewPost returns a post bound to db. +func NewPost(db *sql.DB) *Post { + return &Post{db: db} +} + +// Create inserts a root post or reply according to ParentID. +func (p *Post) Create(ctx context.Context) error { + if p == nil || p.db == nil { + return fmt.Errorf("post: no database") + } + if err := preparePost(p); err != nil { + return err + } + err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{ + ID: p.ID, + ParentID: nullableParentID(p.ParentID), + AuthorID: p.AuthorID, + Title: p.Title, + Body: p.Body, + City: p.City, + PostDate: p.PostDate, + PostState: string(p.PostState), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + }) + return mapPostCreateError(err) +} + +// Update changes only the post body and update timestamp. +func (p *Post) Update(ctx context.Context) error { + if p == nil || p.db == nil { + return fmt.Errorf("post: no database") + } + p.Body = strings.TrimSpace(p.Body) + if p.Body == "" { + return fmt.Errorf("%w: body is required", ErrInvalidPost) + } + p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{ + ID: p.ID, + Body: p.Body, + UpdatedAt: p.UpdatedAt, + }) + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func preparePost(p *Post) error { + p.ID = strings.TrimSpace(p.ID) + p.AuthorID = strings.TrimSpace(p.AuthorID) + p.Title = strings.TrimSpace(p.Title) + p.Body = strings.TrimSpace(p.Body) + p.City = strings.TrimSpace(p.City) + p.PostDate = strings.TrimSpace(p.PostDate) + if p.PostState == "" { + p.PostState = PostStateVisible + } + switch p.PostState { + case PostStateVisible, PostStateHidden, PostStateLocked: + default: + return fmt.Errorf("%w: invalid post state", ErrInvalidPost) + } + if p.AuthorID == "" { + return fmt.Errorf("%w: author is required", ErrInvalidPost) + } + if p.Body == "" { + return fmt.Errorf("%w: body is required", ErrInvalidPost) + } + if p.ParentID == nil { + if p.Title == "" { + return fmt.Errorf("%w: root title is required", ErrInvalidPost) + } + if p.PostDate == "" { + p.PostDate = pacific.Today() + } + } else { + parentID := strings.TrimSpace(*p.ParentID) + if parentID == "" { + return fmt.Errorf("%w: parent is required", ErrInvalidPost) + } + p.ParentID = &parentID + if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible { + return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost) + } + } + if p.ID == "" { + p.ID = uuid.NewString() + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if p.CreatedAt == "" { + p.CreatedAt = now + } + if p.UpdatedAt == "" { + p.UpdatedAt = p.CreatedAt + } + return nil +} + +func nullableParentID(parentID *string) sql.NullString { + if parentID == nil { + return sql.NullString{} + } + return sql.NullString{String: *parentID, Valid: true} +} + +func parentIDFromNull(parentID sql.NullString) *string { + if !parentID.Valid { + return nil + } + id := parentID.String + return &id +} + +func mapPostCreateError(err error) error { + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23503", "23505", "23514": + return fmt.Errorf("%w: %v", ErrInvalidPost, err) + } + } + return err +} + +func postFromValues( + db *sql.DB, + id string, + parentID sql.NullString, + authorID, authorName, authorRole, title, body, city, postDate string, + postState string, + createdAt, updatedAt string, +) Post { + return Post{ + ID: id, + ParentID: parentIDFromNull(parentID), + AuthorID: authorID, + AuthorName: authorName, + AuthorRole: Role(authorRole), + Title: title, + Body: body, + City: city, + PostDate: postDate, + PostState: PostState(postState), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + db: db, + } +} + +// GetPost returns one post without loading its replies. +func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) { + r, err := sqlc.New(db).GetPost(ctx, id) + if err != nil { + return nil, err + } + p := postFromValues( + db, + r.ID, + r.ParentID, + r.AuthorID, + r.AuthorName, + r.AuthorRole, + r.Title, + r.Body, + r.City, + r.PostDate, + r.PostState, + r.CreatedAt, + r.UpdatedAt, + ) + return &p, nil +} + +// GetPostThread returns a root post with all descendants nested under Replies. +func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) { + rows, err := sqlc.New(db).ListPostThread(ctx, rootID) + if err != nil { + return nil, err + } + posts := make([]Post, 0, len(rows)) + for _, r := range rows { + posts = append(posts, postFromValues( + db, + r.ID, + r.ParentID, + r.AuthorID, + r.AuthorName, + r.AuthorRole, + r.Title, + r.Body, + r.City, + r.PostDate, + r.PostState, + r.CreatedAt, + r.UpdatedAt, + )) + } + return buildPostTree(posts, rootID) +} + +func buildPostTree(posts []Post, rootID string) (*Post, error) { + byID := make(map[string]*Post, len(posts)) + for i := range posts { + posts[i].Replies = nil + byID[posts[i].ID] = &posts[i] + } + root, ok := byID[rootID] + if !ok || root.ParentID != nil { + return nil, sql.ErrNoRows + } + for i := range posts { + post := &posts[i] + if post.ID == rootID { + continue + } + if post.ParentID == nil { + return nil, fmt.Errorf("post %s is not in thread %s", post.ID, rootID) + } + parent, ok := byID[*post.ParentID] + if !ok { + return nil, fmt.Errorf("post %s has missing parent %s", post.ID, *post.ParentID) + } + parent.Replies = append(parent.Replies, post) + } + var sortReplies func(*Post) + sortReplies = func(post *Post) { + sort.Slice(post.Replies, func(i, j int) bool { + if post.Replies[i].CreatedAt != post.Replies[j].CreatedAt { + return post.Replies[i].CreatedAt < post.Replies[j].CreatedAt + } + return post.Replies[i].ID < post.Replies[j].ID + }) + for _, reply := range post.Replies { + sortReplies(reply) + } + } + sortReplies(root) + return root, nil +} + +// ListRootPosts returns visible root posts for a post date. +func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) { + rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{ + ViewerID: viewerID, + RowLimit: HuntListLimit, + PostDate: postDate, + HiddenState: string(PostStateHidden), + }) + if err != nil { + return nil, err + } + posts := make([]Post, 0, len(rows)) + for _, r := range rows { + post := postFromValues( + db, + r.ID, + r.ParentID, + r.AuthorID, + r.AuthorName, + r.AuthorRole, + r.Title, + r.Body, + r.City, + r.PostDate, + r.PostState, + r.CreatedAt, + r.UpdatedAt, + ) + post.Score = int(r.Score) + post.Answered = r.Answered + post.UserVote = int(r.UserVote) + posts = append(posts, post) + } + return posts, nil +} + +// SetPostVote sets value to 1, -1, or 0 on a visible root post. +func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error { + if value != 1 && value != -1 && value != 0 { + return fmt.Errorf("invalid vote") + } + q := sqlc.New(db) + if value == 0 { + visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{ + ID: postID, + HiddenState: string(PostStateHidden), + }) + if err != nil { + return err + } + if !visible { + return ErrPostNotVotable + } + return q.DeletePostVote(ctx, sqlc.DeletePostVoteParams{ + UserID: userID, + PostID: postID, + }) + } + n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ + UserID: userID, + PostID: postID, + Value: int32(value), + HiddenState: string(PostStateHidden), + }) + if err != nil { + return err + } + if n == 0 { + return ErrPostNotVotable + } + return nil +} diff --git a/internal/store/post_test.go b/internal/store/post_test.go new file mode 100644 index 0000000..13f80c5 --- /dev/null +++ b/internal/store/post_test.go @@ -0,0 +1,252 @@ +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) + } + if root.PostState != PostStateVisible { + t.Fatalf("default post state = %q, want visible", root.PostState) + } + + 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", + }, + }, + { + 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) { + 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", + PostState: PostStateHidden, + } + 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) + } + + 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) + } +} + +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/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 new file mode 100644 index 0000000..97353f2 --- /dev/null +++ b/internal/store/sqlc/posts.sql.go @@ -0,0 +1,385 @@ +// 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, post_state, 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 + PostState string + 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.PostState, + 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.post_state, 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 + PostState string + 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.PostState, + &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.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.post_state, 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.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 +` + +type ListPostThreadRow struct { + ID string + ParentID sql.NullString + AuthorID string + AuthorName string + AuthorRole string + Title string + Body string + City string + PostDate string + PostState string + CreatedAt string + UpdatedAt string +} + +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 + } + 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.PostState, + &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.post_state, p.created_at, p.updated_at + FROM posts p + WHERE p.parent_id IS NULL + AND p.post_date = $3 + AND p.post_state <> $4 +), +thread AS ( + 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 + + SELECT thread.root_id, child.id, child.author_id + FROM thread + JOIN posts child ON child.parent_id = thread.post_id +), +answered AS ( + SELECT DISTINCT thread.root_id + FROM thread + JOIN users u ON u.id = thread.author_id + WHERE u.role = 'admin' +), +scores AS ( + 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, + u.name AS author_name, u.role AS author_role, + roots.title, roots.body, roots.city, roots.post_date, + 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 +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 +` + +type ListRootPostsParams struct { + ViewerID string + RowLimit int32 + PostDate string + HiddenState string +} + +type ListRootPostsRow struct { + ID string + ParentID sql.NullString + AuthorID string + AuthorName string + AuthorRole string + Title string + Body string + City string + PostDate string + PostState string + 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, + arg.HiddenState, + ) + 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.PostState, + &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 post_state <> $2 +)::bool +` + +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 +} + +const updatePost = `-- name: UpdatePost :execrows +UPDATE posts +SET + body = $1, + updated_at = $2 +WHERE id = $3 +` + +type UpdatePostParams struct { + 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.Body, arg.UpdatedAt, arg.ID) + 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.post_state <> $4 +ON CONFLICT (user_id, post_id) DO UPDATE +SET value = excluded.value +` + +type UpsertPostVoteOnVisibleRootParams struct { + 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, + arg.HiddenState, + ) + 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 } diff --git a/schema.sql b/schema.sql index 5571cad..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 ( @@ -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,