Add unified post storage #3

Merged
codegirl007 merged 3 commits from posts-store into app 2026-08-27 07:43:26 +00:00
9 changed files with 372 additions and 104 deletions
Showing only changes of commit b481dd2925 - Show all commits
+10 -8
View File
@@ -1,6 +1,6 @@
-- name: CreatePost :exec -- name: CreatePost :exec
INSERT INTO posts ( 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 ( VALUES (
sqlc.arg(id), sqlc.arg(id),
@@ -10,7 +10,7 @@ VALUES (
sqlc.arg(body), sqlc.arg(body),
sqlc.arg(city), sqlc.arg(city),
sqlc.arg(post_date), sqlc.arg(post_date),
sqlc.arg(hidden), sqlc.arg(post_state),
sqlc.arg(created_at), sqlc.arg(created_at),
sqlc.arg(updated_at) sqlc.arg(updated_at)
); );
@@ -18,7 +18,7 @@ VALUES (
-- name: GetPost :one -- name: GetPost :one
SELECT SELECT
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, 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 FROM posts p
JOIN users u ON u.id = p.author_id JOIN users u ON u.id = p.author_id
WHERE p.id = sqlc.arg(id); WHERE p.id = sqlc.arg(id);
@@ -39,7 +39,7 @@ SELECT
thread.id, thread.parent_id, thread.author_id, thread.id, thread.parent_id, thread.author_id,
u.name AS author_name, u.role AS author_role, u.name AS author_name, u.role AS author_role,
thread.title, thread.body, thread.city, thread.post_date, 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 FROM thread
JOIN users u ON u.id = thread.author_id JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.id; ORDER BY thread.created_at, thread.id;
@@ -57,7 +57,7 @@ WITH RECURSIVE roots AS (
FROM posts p FROM posts p
WHERE p.parent_id IS NULL WHERE p.parent_id IS NULL
AND p.post_date = sqlc.arg(post_date) AND p.post_date = sqlc.arg(post_date)
AND p.hidden = 0 AND p.post_state <> sqlc.arg(hidden_state)
), ),
thread AS ( thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id 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, roots.id, roots.parent_id, roots.author_id,
u.name AS author_name, u.role AS author_role, u.name AS author_name, u.role AS author_role,
roots.title, roots.body, roots.city, roots.post_date, 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, COALESCE(scores.score, 0)::bigint AS score,
(answered.root_id IS NOT NULL)::bool AS answered, (answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(viewer_vote.value, 0)::bigint AS user_vote COALESCE(viewer_vote.value, 0)::bigint AS user_vote
@@ -104,7 +104,9 @@ LIMIT sqlc.arg(row_limit);
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM posts 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; )::bool;
-- name: DeletePostVote :exec -- name: DeletePostVote :exec
@@ -118,6 +120,6 @@ SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value)
FROM posts p FROM posts p
WHERE p.id = sqlc.arg(post_id) WHERE p.id = sqlc.arg(post_id)
AND p.parent_id IS NULL 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 ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value; SET value = excluded.value;
+2 -2
View File
@@ -463,7 +463,7 @@ func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]
defer m.mu.Unlock() defer m.mu.Unlock()
posts := make([]Post, 0) posts := make([]Post, 0)
for _, post := range m.posts { 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 continue
} }
cp := clonePostWithAuthor(post, m.users) 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") return fmt.Errorf("invalid vote")
} }
post, ok := m.posts[postID] post, ok := m.posts[postID]
if !ok || post.ParentID != nil || post.Hidden { if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
return ErrPostNotVotable return ErrPostNotVotable
} }
if m.postVotes[postID] == nil { if m.postVotes[postID] == nil {
+124 -20
View File
@@ -41,8 +41,9 @@ func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
sql string sql string
args []any
}{ }{
{"create posts", ` {name: "create posts", sql: `
CREATE TABLE IF NOT EXISTS posts ( CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE, parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
@@ -51,53 +52,56 @@ CREATE TABLE IF NOT EXISTS posts (
body TEXT NOT NULL, body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '',
post_date 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK ( CONSTRAINT posts_shape_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '') (parent_id IS NULL AND title <> '' AND post_date <> '')
OR 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 CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id)`}, ON posts(parent_id, created_at, id)`},
{"index root posts", ` {name: "index root posts", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_root_date 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`}, WHERE parent_id IS NULL`},
{"create post votes", ` {name: "create post votes", sql: `
CREATE TABLE IF NOT EXISTS post_votes ( CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)), value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id) PRIMARY KEY (user_id, post_id)
)`}, )`},
{"copy questions", ` {name: "copy questions", sql: `
INSERT INTO posts ( 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 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 FROM questions
ON CONFLICT (id) DO NOTHING`}, ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}},
{"copy answers", ` {name: "copy answers", sql: `
INSERT INTO posts ( 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 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 FROM answers
ON CONFLICT (id) DO NOTHING`}, ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}},
{"copy votes", ` {name: "copy votes", sql: `
INSERT INTO post_votes (user_id, post_id, value) INSERT INTO post_votes (user_id, post_id, value)
SELECT user_id, question_id, value SELECT user_id, question_id, value
FROM votes FROM votes
ON CONFLICT (user_id, post_id) DO NOTHING`}, ON CONFLICT (user_id, post_id) DO NOTHING`},
} }
for _, step := range steps { 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) return fmt.Errorf("%s: %w", step.name, err)
} }
} }
@@ -141,8 +145,79 @@ $migration$`},
{"drop legacy root date index", ` {"drop legacy root date index", `
DROP INDEX IF EXISTS idx_posts_root_hunt`}, DROP INDEX IF EXISTS idx_posts_root_hunt`},
{"create root date index", ` {"create root date index", `
CREATE INDEX IF NOT EXISTS idx_posts_root_date DO $migration$
ON posts(post_date, hidden) 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`}, WHERE parent_id IS NULL`},
} }
for _, step := range steps { for _, step := range steps {
@@ -153,6 +228,34 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date
return nil 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 { type execContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, 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}, {"004_posts", migratePosts},
{"005_post_vote_post_id_index", migratePostVoteIndex}, {"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate}, {"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+99 -13
View File
@@ -145,9 +145,9 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
} }
var rootParent sql.NullString 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, ` 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 FROM posts
WHERE id = 'question-1'`).Scan( WHERE id = 'question-1'`).Scan(
&rootParent, &rootParent,
@@ -156,6 +156,7 @@ WHERE id = 'question-1'`).Scan(
&rootBody, &rootBody,
&city, &city,
&postDate, &postDate,
&rootState,
&rootCreated, &rootCreated,
&rootUpdated, &rootUpdated,
); err != nil { ); err != nil {
@@ -167,19 +168,21 @@ WHERE id = 'question-1'`).Scan(
rootBody != "It drips." || rootBody != "It drips." ||
city != "Oakland" || city != "Oakland" ||
postDate != "2026-08-26" || postDate != "2026-08-26" ||
rootState != "visible" ||
rootCreated != "2026-08-26T08:00:00Z" || rootCreated != "2026-08-26T08:00:00Z" ||
rootUpdated != rootCreated { rootUpdated != rootCreated {
t.Fatalf("unexpected root post") 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, ` 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 FROM posts
WHERE id = 'answer:question-1'`).Scan( WHERE id = 'answer:question-1'`).Scan(
&replyParent, &replyParent,
&replyAuthor, &replyAuthor,
&replyBody, &replyBody,
&replyState,
&replyCreated, &replyCreated,
&replyUpdated, &replyUpdated,
); err != nil { ); err != nil {
@@ -188,6 +191,7 @@ WHERE id = 'answer:question-1'`).Scan(
if replyParent != "question-1" || if replyParent != "question-1" ||
replyAuthor != "plumber" || replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." || replyBody != "Replace the cartridge." ||
replyState != "visible" ||
replyCreated != "2026-08-26T09:00:00Z" || replyCreated != "2026-08-26T09:00:00Z" ||
replyUpdated != "2026-08-26T09:05:00Z" { replyUpdated != "2026-08-26T09:05:00Z" {
t.Fatalf("unexpected reply post") 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, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO posts ( 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 ( ) 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 { )`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded") 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}, ParentID: sql.NullString{String: "answer:question-1", Valid: true},
AuthorID: "homeowner", AuthorID: "homeowner",
Body: "It is still dripping.", Body: "It is still dripping.",
PostState: string(PostStateVisible),
CreatedAt: "2026-08-26T10:00:00Z", CreatedAt: "2026-08-26T10:00:00Z",
UpdatedAt: "2026-08-26T10:00:00Z", UpdatedAt: "2026-08-26T10:00:00Z",
}); err != nil { }); err != nil {
@@ -248,16 +254,18 @@ INSERT INTO posts (
t.Fatalf("update rows=%d error=%v", n, err) t.Fatalf("update rows=%d error=%v", n, err)
} }
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber", UserID: "plumber",
PostID: "question-1", PostID: "question-1",
Value: 1, Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 { }); err != nil || n != 1 {
t.Fatalf("vote rows=%d error=%v", n, err) t.Fatalf("vote rows=%d error=%v", n, err)
} }
roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{ roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{
ViewerID: "plumber", ViewerID: "plumber",
RowLimit: 100, RowLimit: 100,
PostDate: "2026-08-26", PostDate: "2026-08-26",
HiddenState: string(PostStateHidden),
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -273,7 +281,7 @@ INSERT INTO posts (
DROP INDEX idx_posts_root_date; DROP INDEX idx_posts_root_date;
ALTER TABLE posts RENAME COLUMN post_date TO hunt_date; ALTER TABLE posts RENAME COLUMN post_date TO hunt_date;
CREATE INDEX idx_posts_root_hunt CREATE INDEX idx_posts_root_hunt
ON posts(hunt_date, hidden) ON posts(hunt_date, post_state)
WHERE parent_id IS NULL;`); err != nil { WHERE parent_id IS NULL;`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -322,6 +330,84 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e
migratedPostDate, 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) { func TestMigratePostsReportsStep(t *testing.T) {
+36 -22
View File
@@ -21,6 +21,14 @@ var (
ErrPostNotVotable = errors.New("post not votable") 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. // Post is either a root question (ParentID nil) or a reply to another post.
type Post struct { type Post struct {
ID string ID string
@@ -32,7 +40,7 @@ type Post struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden bool PostState PostState
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
Score int Score int
@@ -63,7 +71,7 @@ func (p *Post) Create(ctx context.Context) error {
Body: p.Body, Body: p.Body,
City: p.City, City: p.City,
PostDate: p.PostDate, PostDate: p.PostDate,
Hidden: boolInt32(p.Hidden), PostState: string(p.PostState),
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
}) })
@@ -101,6 +109,14 @@ func preparePost(p *Post) error {
p.Body = strings.TrimSpace(p.Body) p.Body = strings.TrimSpace(p.Body)
p.City = strings.TrimSpace(p.City) p.City = strings.TrimSpace(p.City)
p.PostDate = strings.TrimSpace(p.PostDate) 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 == "" { if p.AuthorID == "" {
return fmt.Errorf("%w: author is required", ErrInvalidPost) 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) return fmt.Errorf("%w: parent is required", ErrInvalidPost)
} }
p.ParentID = &parentID 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) return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
} }
} }
@@ -152,13 +168,6 @@ func parentIDFromNull(parentID sql.NullString) *string {
return &id return &id
} }
func boolInt32(value bool) int32 {
if value {
return 1
}
return 0
}
func mapPostCreateError(err error) error { func mapPostCreateError(err error) error {
if err == nil { if err == nil {
return nil return nil
@@ -178,7 +187,7 @@ func postFromValues(
id string, id string,
parentID sql.NullString, parentID sql.NullString,
authorID, authorName, authorRole, title, body, city, postDate string, authorID, authorName, authorRole, title, body, city, postDate string,
hidden int32, postState string,
createdAt, updatedAt string, createdAt, updatedAt string,
) Post { ) Post {
return Post{ return Post{
@@ -191,7 +200,7 @@ func postFromValues(
Body: body, Body: body,
City: city, City: city,
PostDate: postDate, PostDate: postDate,
Hidden: hidden != 0, PostState: PostState(postState),
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
db: db, db: db,
@@ -215,7 +224,7 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r.Body, r.Body,
r.City, r.City,
r.PostDate, r.PostDate,
r.Hidden, r.PostState,
r.CreatedAt, r.CreatedAt,
r.UpdatedAt, r.UpdatedAt,
) )
@@ -241,7 +250,7 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
r.Body, r.Body,
r.City, r.City,
r.PostDate, r.PostDate,
r.Hidden, r.PostState,
r.CreatedAt, r.CreatedAt,
r.UpdatedAt, r.UpdatedAt,
)) ))
@@ -292,9 +301,10 @@ func buildPostTree(posts []Post, rootID string) (*Post, error) {
// ListRootPosts returns visible root posts for a post date. // ListRootPosts returns visible root posts for a post date.
func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) { func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{ rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{
ViewerID: viewerID, ViewerID: viewerID,
RowLimit: HuntListLimit, RowLimit: HuntListLimit,
PostDate: postDate, PostDate: postDate,
HiddenState: string(PostStateHidden),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -312,7 +322,7 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) (
r.Body, r.Body,
r.City, r.City,
r.PostDate, r.PostDate,
r.Hidden, r.PostState,
r.CreatedAt, r.CreatedAt,
r.UpdatedAt, r.UpdatedAt,
) )
@@ -331,7 +341,10 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i
} }
q := sqlc.New(db) q := sqlc.New(db)
if value == 0 { if value == 0 {
visible, err := q.PostIsVisibleRoot(ctx, postID) visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
ID: postID,
HiddenState: string(PostStateHidden),
})
if err != nil { if err != nil {
return err 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{ n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: userID, UserID: userID,
PostID: postID, PostID: postID,
Value: int32(value), Value: int32(value),
HiddenState: string(PostStateHidden),
}) })
if err != nil { if err != nil {
return err return err
+49 -6
View File
@@ -50,6 +50,9 @@ func TestMemoryPostLifecycle(t *testing.T) {
if err := mem.CreatePost(ctx, root); err != nil { if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if root.PostState != PostStateVisible {
t.Fatalf("default post state = %q, want visible", root.PostState)
}
rootID := root.ID rootID := root.ID
later := &Post{ later := &Post{
@@ -175,6 +178,24 @@ func TestMemoryPostValidation(t *testing.T) {
Body: "Body", 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -185,12 +206,12 @@ func TestMemoryPostValidation(t *testing.T) {
} }
hidden := &Post{ hidden := &Post{
ID: "hidden", ID: "hidden",
AuthorID: homeowner.ID, AuthorID: homeowner.ID,
Title: "Hidden", Title: "Hidden",
Body: "Body", Body: "Body",
PostDate: "2026-08-26", PostDate: "2026-08-26",
Hidden: true, PostState: PostStateHidden,
} }
if err := mem.CreatePost(ctx, hidden); err != nil { if err := mem.CreatePost(ctx, hidden); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -199,6 +220,28 @@ func TestMemoryPostValidation(t *testing.T) {
t.Fatalf("hidden root vote error = %v", err) 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) { if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("missing thread error = %v", err) t.Fatalf("missing thread error = %v", err)
} }
+1 -1
View File
@@ -25,7 +25,7 @@ type Post struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden int32 PostState string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
+47 -28
View File
@@ -12,7 +12,7 @@ import (
const createPost = `-- name: CreatePost :exec const createPost = `-- name: CreatePost :exec
INSERT INTO posts ( 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 ( VALUES (
$1, $1,
@@ -36,7 +36,7 @@ type CreatePostParams struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden int32 PostState string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
@@ -50,7 +50,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
arg.Body, arg.Body,
arg.City, arg.City,
arg.PostDate, arg.PostDate,
arg.Hidden, arg.PostState,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt, arg.UpdatedAt,
) )
@@ -76,7 +76,7 @@ func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams)
const getPost = `-- name: GetPost :one const getPost = `-- name: GetPost :one
SELECT SELECT
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role, 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 FROM posts p
JOIN users u ON u.id = p.author_id JOIN users u ON u.id = p.author_id
WHERE p.id = $1 WHERE p.id = $1
@@ -92,7 +92,7 @@ type GetPostRow struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden int32 PostState string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
@@ -110,7 +110,7 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
&i.Body, &i.Body,
&i.City, &i.City,
&i.PostDate, &i.PostDate,
&i.Hidden, &i.PostState,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -119,13 +119,13 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
const listPostThread = `-- name: ListPostThread :many const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS ( 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 FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL WHERE p.id = $1 AND p.parent_id IS NULL
UNION ALL 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 FROM posts child
JOIN thread parent ON child.parent_id = parent.id JOIN thread parent ON child.parent_id = parent.id
) )
@@ -133,7 +133,7 @@ SELECT
thread.id, thread.parent_id, thread.author_id, thread.id, thread.parent_id, thread.author_id,
u.name AS author_name, u.role AS author_role, u.name AS author_name, u.role AS author_role,
thread.title, thread.body, thread.city, thread.post_date, 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 FROM thread
JOIN users u ON u.id = thread.author_id JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.id ORDER BY thread.created_at, thread.id
@@ -149,7 +149,7 @@ type ListPostThreadRow struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden int32 PostState string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
@@ -173,7 +173,7 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
&i.Body, &i.Body,
&i.City, &i.City,
&i.PostDate, &i.PostDate,
&i.Hidden, &i.PostState,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -192,11 +192,11 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
const listRootPosts = `-- name: ListRootPosts :many const listRootPosts = `-- name: ListRootPosts :many
WITH RECURSIVE roots AS ( 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 FROM posts p
WHERE p.parent_id IS NULL WHERE p.parent_id IS NULL
AND p.post_date = $3 AND p.post_date = $3
AND p.hidden = 0 AND p.post_state <> $4
), ),
thread AS ( thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id 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, roots.id, roots.parent_id, roots.author_id,
u.name AS author_name, u.role AS author_role, u.name AS author_name, u.role AS author_role,
roots.title, roots.body, roots.city, roots.post_date, 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, COALESCE(scores.score, 0)::bigint AS score,
(answered.root_id IS NOT NULL)::bool AS answered, (answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(viewer_vote.value, 0)::bigint AS user_vote COALESCE(viewer_vote.value, 0)::bigint AS user_vote
@@ -241,9 +241,10 @@ LIMIT $2
` `
type ListRootPostsParams struct { type ListRootPostsParams struct {
ViewerID string ViewerID string
RowLimit int32 RowLimit int32
PostDate string PostDate string
HiddenState string
} }
type ListRootPostsRow struct { type ListRootPostsRow struct {
@@ -256,7 +257,7 @@ type ListRootPostsRow struct {
Body string Body string
City string City string
PostDate string PostDate string
Hidden int32 PostState string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
Score int64 Score int64
@@ -265,7 +266,12 @@ type ListRootPostsRow struct {
} }
func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -283,7 +289,7 @@ func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([
&i.Body, &i.Body,
&i.City, &i.City,
&i.PostDate, &i.PostDate,
&i.Hidden, &i.PostState,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Score, &i.Score,
@@ -307,12 +313,19 @@ const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM posts 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 )::bool
` `
func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error) { type PostIsVisibleRootParams struct {
row := q.db.QueryRowContext(ctx, postIsVisibleRoot, id) 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 var column_1 bool
err := row.Scan(&column_1) err := row.Scan(&column_1)
return column_1, err return column_1, err
@@ -346,19 +359,25 @@ SELECT $1, $2, $3
FROM posts p FROM posts p
WHERE p.id = $2 WHERE p.id = $2
AND p.parent_id IS NULL AND p.parent_id IS NULL
AND p.hidden = 0 AND p.post_state <> $4
ON CONFLICT (user_id, post_id) DO UPDATE ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value SET value = excluded.value
` `
type UpsertPostVoteOnVisibleRootParams struct { type UpsertPostVoteOnVisibleRootParams struct {
UserID string UserID string
PostID string PostID string
Value int32 Value int32
HiddenState string
} }
func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) { 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 { if err != nil {
return 0, err return 0, err
} }
+4 -4
View File
@@ -50,13 +50,13 @@ CREATE TABLE IF NOT EXISTS posts (
body TEXT NOT NULL, body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '',
post_date 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK ( CONSTRAINT posts_shape_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '') (parent_id IS NULL AND title <> '' AND post_date <> '')
OR 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); ON posts(parent_id, created_at, id);
CREATE INDEX IF NOT EXISTS idx_posts_root_date 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; WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS post_votes ( CREATE TABLE IF NOT EXISTS post_votes (