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 (