diff --git a/db/queries/answers.sql b/db/queries/answers.sql
deleted file mode 100644
index bd1c59d..0000000
--- a/db/queries/answers.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- name: UpsertAnswer :exec
-INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
-VALUES ($1, $2, $3, $4, $5)
-ON CONFLICT (question_id) DO UPDATE
-SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at;
-
--- name: GetAnswer :one
-SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
-FROM answers a
-JOIN users u ON u.id = a.author_id
-WHERE a.question_id = $1;
diff --git a/db/queries/questions.sql b/db/queries/questions.sql
deleted file mode 100644
index 70d62cb..0000000
--- a/db/queries/questions.sql
+++ /dev/null
@@ -1,62 +0,0 @@
--- name: CreateQuestion :exec
-INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
-VALUES ($1, $2, $3, $4, $5, $6, 0, $7);
-
--- name: HideQuestion :exec
-UPDATE questions
-SET hidden = 1
-WHERE id = $1;
-
--- name: ListHunt :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE(SUM(v.value), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- COALESCE((
- SELECT votes.value FROM votes
- WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
- ), 0)::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN votes v ON v.question_id = q.id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
-GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
-ORDER BY score DESC, q.created_at ASC
-LIMIT sqlc.arg(row_limit);
-
--- name: GetQuestion :one
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- COALESCE((
- SELECT votes.value FROM votes
- WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
- ), 0)::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.id = sqlc.arg(id);
-
--- name: ListQuestionsByAuthor :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- 0::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
-ORDER BY q.created_at DESC
-LIMIT sqlc.arg(row_limit);
-
--- name: ListQuestionsAnsweredBy :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- 1::bigint AS answered,
- 0::bigint AS user_vote
-FROM answers ans
-JOIN questions q ON q.id = ans.question_id
-JOIN users u ON u.id = q.author_id
-WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
-ORDER BY ans.updated_at DESC
-LIMIT sqlc.arg(row_limit);
diff --git a/db/queries/votes.sql b/db/queries/votes.sql
deleted file mode 100644
index a8917ba..0000000
--- a/db/queries/votes.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- name: GetVote :one
-SELECT value
-FROM votes
-WHERE user_id = $1 AND question_id = $2;
-
--- name: QuestionIsVisible :one
-SELECT EXISTS(
- SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
-)::bool;
-
--- name: DeleteVote :exec
-DELETE FROM votes
-WHERE user_id = $1 AND question_id = $2;
-
--- name: UpsertVoteOnVisible :execrows
-INSERT INTO votes (user_id, question_id, value)
-SELECT $1, $2, $3
-FROM questions q
-WHERE q.id = $2 AND q.hidden = 0
-ON CONFLICT (user_id, question_id) DO UPDATE
-SET value = excluded.value
-WHERE EXISTS (
- SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
-);
diff --git a/internal/store/answer.go b/internal/store/answer.go
deleted file mode 100644
index 9dcfe3f..0000000
--- a/internal/store/answer.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "fmt"
- "strings"
- "time"
-
- "plumber/internal/store/sqlc"
-)
-
-// Answer is an admin reply to a question.
-type Answer struct {
- QuestionID string
- AuthorID string
- AuthorName string
- Body string
- CreatedAt string
- UpdatedAt string
- db *sql.DB
-}
-
-// NewAnswer returns an Answer bound to db.
-func NewAnswer(db *sql.DB) *Answer {
- return &Answer{db: db}
-}
-
-// Upsert inserts or updates the answer for QuestionID.
-func (a *Answer) Upsert(ctx context.Context) error {
- if a == nil || a.db == nil {
- return fmt.Errorf("answer: no database")
- }
- a.Body = strings.TrimSpace(a.Body)
- now := time.Now().UTC().Format(time.RFC3339)
- if a.CreatedAt == "" {
- a.CreatedAt = now
- }
- a.UpdatedAt = now
- return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{
- QuestionID: a.QuestionID,
- AuthorID: a.AuthorID,
- Body: a.Body,
- CreatedAt: a.CreatedAt,
- UpdatedAt: a.UpdatedAt,
- })
-}
-
-func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
- r, err := sqlc.New(db).GetAnswer(ctx, questionID)
- if err != nil {
- return nil, err
- }
- return &Answer{
- QuestionID: r.QuestionID,
- AuthorID: r.AuthorID,
- AuthorName: r.AuthorName,
- Body: r.Body,
- CreatedAt: r.CreatedAt,
- UpdatedAt: r.UpdatedAt,
- db: db,
- }, nil
-}
diff --git a/internal/store/memory.go b/internal/store/memory.go
index b182724..fff0cfc 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -10,20 +10,15 @@ import (
"time"
"github.com/google/uuid"
-
- "plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
type Memory struct {
mu sync.Mutex
- users map[string]*User // id -> user
- byName map[string]string // username -> id
- 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
+ users map[string]*User // id -> user
+ byName map[string]string // username -> id
+ posts map[string]*Post // id -> post
+ postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
@@ -31,9 +26,6 @@ func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
- questions: map[string]*RankedQuestion{},
- answers: map[string]*Answer{},
- votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
@@ -210,172 +202,6 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
return nil
}
-func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- q.Title = strings.TrimSpace(q.Title)
- q.Body = strings.TrimSpace(q.Body)
- q.City = strings.TrimSpace(q.City)
- if q.ID == "" {
- q.ID = uuid.NewString()
- }
- if q.HuntDate == "" {
- q.HuntDate = pacific.Today()
- }
- if q.CreatedAt == "" {
- q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
- }
- author, ok := m.users[q.AuthorID]
- if !ok {
- return fmt.Errorf("unknown author")
- }
- cp := *q
- cp.AuthorName = author.Name
- cp.db = nil
- m.questions[cp.ID] = &cp
- *q = cp
- return nil
-}
-
-func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
- out := *q
- score := 0
- userVote := 0
- if votes, ok := m.votes[q.ID]; ok {
- for uid, v := range votes {
- score += v
- if uid == viewerID {
- userVote = v
- }
- }
- }
- _, answered := m.answers[q.ID]
- out.Score = score
- out.Answered = answered
- out.UserVote = userVote
- out.db = nil
- return out
-}
-
-func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- q, ok := m.questions[id]
- if !ok {
- return nil, sql.ErrNoRows
- }
- out := m.annotate(q, viewerID)
- return &out, nil
-}
-
-func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- out := make([]RankedQuestion, 0)
- for _, q := range m.questions {
- if q.HuntDate != huntDate || q.Hidden {
- continue
- }
- out = append(out, m.annotate(q, viewerID))
- }
- sort.Slice(out, func(i, j int) bool {
- if out[i].Score != out[j].Score {
- return out[i].Score > out[j].Score
- }
- return out[i].CreatedAt < out[j].CreatedAt
- })
- if len(out) > HuntListLimit {
- out = out[:HuntListLimit]
- }
- return out, nil
-}
-
-func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- out := make([]RankedQuestion, 0)
- for _, q := range m.questions {
- if q.AuthorID != authorID || q.Hidden {
- continue
- }
- out = append(out, m.annotate(q, ""))
- }
- sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
- if len(out) > ProfileListLimit {
- out = out[:ProfileListLimit]
- }
- return out, nil
-}
-
-func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- out := make([]RankedQuestion, 0)
- for qid, a := range m.answers {
- if a.AuthorID != adminID {
- continue
- }
- q, ok := m.questions[qid]
- if !ok || q.Hidden {
- continue
- }
- rq := m.annotate(q, "")
- rq.Answered = true
- out = append(out, rq)
- }
- sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
- if len(out) > ProfileListLimit {
- out = out[:ProfileListLimit]
- }
- return out, nil
-}
-
-func (m *Memory) HideQuestion(_ context.Context, id string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- q, ok := m.questions[id]
- if !ok {
- return sql.ErrNoRows
- }
- q.Hidden = true
- return nil
-}
-
-func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- a, ok := m.answers[questionID]
- if !ok {
- return nil, sql.ErrNoRows
- }
- cp := *a
- if u, ok := m.users[a.AuthorID]; ok {
- cp.AuthorName = u.Name
- }
- return &cp, nil
-}
-
-func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if _, ok := m.questions[a.QuestionID]; !ok {
- return fmt.Errorf("unknown question")
- }
- a.Body = strings.TrimSpace(a.Body)
- now := time.Now().UTC().Format(time.RFC3339)
- if existing, ok := m.answers[a.QuestionID]; ok {
- a.CreatedAt = existing.CreatedAt
- } else if a.CreatedAt == "" {
- a.CreatedAt = now
- }
- a.UpdatedAt = now
- cp := *a
- cp.db = nil
- m.answers[a.QuestionID] = &cp
- *a = cp
- return nil
-}
-
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -637,24 +463,3 @@ func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
}
return cp
}
-
-func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if value != 1 && value != -1 && value != 0 {
- return fmt.Errorf("invalid vote")
- }
- q, ok := m.questions[questionID]
- if !ok || q.Hidden {
- return ErrHiddenOrMissing
- }
- if m.votes[questionID] == nil {
- m.votes[questionID] = map[string]int{}
- }
- if value == 0 {
- delete(m.votes[questionID], userID)
- return nil
- }
- m.votes[questionID][userID] = value
- return nil
-}
diff --git a/internal/store/migrate.go b/internal/store/migrate.go
index 406e4aa..dee8fcd 100644
--- a/internal/store/migrate.go
+++ b/internal/store/migrate.go
@@ -35,13 +35,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
return nil
}
-// migratePosts creates the unified post model and snapshots legacy content.
-// Legacy tables remain in place until the application cutover is complete.
+// migratePosts creates the unified post model.
func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
- args []any
}{
{name: "create posts", sql: `
CREATE TABLE IF NOT EXISTS posts (
@@ -78,33 +76,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id)
)`},
- {name: "copy questions", sql: `
-INSERT INTO posts (
- 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,
- CASE WHEN hidden = 0 THEN $1 ELSE $2 END,
- created_at, created_at
-FROM questions
-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, post_state, created_at, updated_at
-)
-SELECT
- 'answer:' || question_id, question_id, author_id, '', body, '', '',
- $1, created_at, updated_at
-FROM answers
-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 {
+ if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
@@ -129,6 +103,23 @@ CREATE INDEX IF NOT EXISTS idx_posts_author_created
return nil
}
+func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
+ steps := []struct {
+ name string
+ sql string
+ }{
+ {"drop legacy answers", `DROP TABLE IF EXISTS answers`},
+ {"drop legacy votes", `DROP TABLE IF EXISTS votes`},
+ {"drop legacy questions", `DROP TABLE IF EXISTS questions`},
+ }
+ for _, step := range steps {
+ if _, err := exec.ExecContext(ctx, step.sql); err != nil {
+ return fmt.Errorf("%s: %w", step.name, err)
+ }
+ }
+ return nil
+}
+
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -319,6 +310,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
+ {"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
}
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 3f66740..4afcb4f 100644
--- a/internal/store/migrate_posts_test.go
+++ b/internal/store/migrate_posts_test.go
@@ -13,7 +13,7 @@ import (
"plumber/internal/store/sqlc"
)
-func TestMigratePostsCopiesLegacyData(t *testing.T) {
+func TestPostMigrationsAndQueries(t *testing.T) {
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if rawURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
@@ -47,47 +47,13 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) {
t.Fatal(err)
}
- legacySchema := `
+ baseSchema := `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL
-);
-CREATE TABLE questions (
- id TEXT PRIMARY KEY,
- author_id TEXT NOT NULL REFERENCES users(id),
- title TEXT NOT NULL,
- body TEXT NOT NULL,
- city TEXT NOT NULL DEFAULT '',
- hunt_date TEXT NOT NULL,
- hidden INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL
-);
-CREATE TABLE answers (
- question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
- author_id TEXT NOT NULL REFERENCES users(id),
- body TEXT NOT NULL,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-CREATE TABLE votes (
- user_id TEXT NOT NULL REFERENCES users(id),
- question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
- value INTEGER NOT NULL CHECK (value IN (-1, 1)),
- PRIMARY KEY (user_id, question_id)
);`
- if err := applySchema(ctx, conn, legacySchema); err != nil {
- t.Fatal(err)
- }
- if _, err := conn.ExecContext(ctx, `
-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)
-VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
-INSERT INTO votes (user_id, question_id, value)
-VALUES ('homeowner', 'question-1', 1);`); err != nil {
+ if err := applySchema(ctx, conn, baseSchema); err != nil {
t.Fatal(err)
}
@@ -103,28 +69,40 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index migration is not idempotent: %v", err)
}
+ if err := migratePostAuthorIndex(ctx, conn); err != nil {
+ t.Fatal(err)
+ }
+ if err := migratePostAuthorIndex(ctx, conn); err != nil {
+ t.Fatalf("post author index migration is not idempotent: %v", err)
+ }
+ if _, err := conn.ExecContext(ctx, `
+INSERT INTO users (id, name, role)
+VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
+INSERT INTO posts (
+ id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
+) VALUES
+ (
+ 'root-1', NULL, 'homeowner', 'Leaky sink', 'It drips.', 'Oakland',
+ '2026-08-26', 'visible', '2026-08-26T08:00:00Z', '2026-08-26T08:00:00Z'
+ ),
+ (
+ 'reply-1', 'root-1', 'plumber', '', 'Replace the cartridge.', '', '',
+ 'visible', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z'
+ );
+INSERT INTO post_votes (user_id, post_id, value)
+VALUES ('homeowner', 'root-1', 1);`); err != nil {
+ t.Fatal(err)
+ }
- var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
+ var postCount, voteCount int
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
t.Fatal(err)
}
- if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
- t.Fatal(err)
- }
- if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
- t.Fatal(err)
- }
- if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
- t.Fatalf(
- "counts posts=%d votes=%d legacy questions=%d answers=%d",
- postCount,
- voteCount,
- legacyQuestionCount,
- legacyAnswerCount,
- )
+ if postCount != 2 || voteCount != 1 {
+ t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
@@ -152,7 +130,7 @@ WHERE schemaname = current_schema()
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
-VALUES ('homeowner', 'question-1', -1)`); err == nil {
+VALUES ('homeowner', 'root-1', -1)`); err == nil {
t.Fatal("duplicate user/post vote unexpectedly succeeded")
}
@@ -161,7 +139,7 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
FROM posts
-WHERE id = 'question-1'`).Scan(
+WHERE id = 'root-1'`).Scan(
&rootParent,
&rootAuthor,
&title,
@@ -190,7 +168,7 @@ WHERE id = 'question-1'`).Scan(
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, body, post_state, created_at, updated_at
FROM posts
-WHERE id = 'answer:question-1'`).Scan(
+WHERE id = 'reply-1'`).Scan(
&replyParent,
&replyAuthor,
&replyBody,
@@ -200,7 +178,7 @@ WHERE id = 'answer:question-1'`).Scan(
); err != nil {
t.Fatal(err)
}
- if replyParent != "question-1" ||
+ if replyParent != "root-1" ||
replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." ||
replyState != "visible" ||
@@ -212,7 +190,7 @@ WHERE id = 'answer:question-1'`).Scan(
var voteValue int
if err := conn.QueryRowContext(ctx, `
SELECT value FROM post_votes
-WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
+WHERE user_id = 'homeowner' AND post_id = 'root-1'`).Scan(&voteValue); err != nil {
t.Fatal(err)
}
if voteValue != 1 {
@@ -223,7 +201,7 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err !
INSERT INTO posts (
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', '', '',
+ 'invalid-reply', 'root-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'visible', 'now', 'now'
)`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded")
@@ -232,7 +210,7 @@ INSERT INTO posts (
queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up",
- ParentID: sql.NullString{String: "answer:question-1", Valid: true},
+ ParentID: sql.NullString{String: "reply-1", Valid: true},
AuthorID: "homeowner",
Body: "It is still dripping.",
PostState: string(PostStateVisible),
@@ -241,17 +219,17 @@ INSERT INTO posts (
}); err != nil {
t.Fatal(err)
}
- thread, err := queries.ListPostThread(ctx, "question-1")
+ thread, err := queries.ListPostThread(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
- thread[0].ID != "question-1" ||
- thread[1].ID != "answer:question-1" ||
+ thread[0].ID != "root-1" ||
+ thread[1].ID != "reply-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
- nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
+ nonRootThread, err := queries.ListPostThread(ctx, "reply-1")
if err != nil {
t.Fatal(err)
}
@@ -267,7 +245,7 @@ INSERT INTO posts (
}
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber",
- PostID: "question-1",
+ PostID: "root-1",
Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 {
@@ -290,7 +268,7 @@ INSERT INTO posts (
}
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber",
- RootID: "question-1",
+ RootID: "root-1",
})
if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err)
@@ -300,7 +278,7 @@ INSERT INTO posts (
HiddenState: string(PostStateHidden),
RowLimit: 50,
})
- if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "question-1" {
+ if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "root-1" {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
@@ -308,14 +286,14 @@ INSERT INTO posts (
AdminID: "plumber",
RowLimit: 50,
})
- if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "question-1" {
+ if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "root-1" {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z",
- ID: "question-1",
+ ID: "root-1",
})
if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
@@ -358,7 +336,7 @@ WHERE schemaname = current_schema()
}
var migratedPostDate string
if err := conn.QueryRowContext(ctx, `
-SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
+SELECT post_date FROM posts WHERE id = 'root-1'`).Scan(&migratedPostDate); err != nil {
t.Fatal(err)
}
if postDateColumnCount != 1 ||
@@ -380,7 +358,7 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e
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;
+UPDATE posts SET hidden = CASE WHEN id = 'root-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 <> '')
@@ -411,11 +389,11 @@ WHERE table_schema = current_schema()
}
var hiddenState, replyStateAfterMigration string
if err := conn.QueryRowContext(ctx, `
-SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
+SELECT post_state FROM posts WHERE id = 'root-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 {
+SELECT post_state FROM posts WHERE id = 'reply-1'`).Scan(&replyStateAfterMigration); err != nil {
t.Fatal(err)
}
var postStateDataType string
@@ -453,15 +431,37 @@ WHERE schemaname = current_schema()
stateIndexCount,
)
}
+
+ if _, err := conn.ExecContext(ctx, `
+CREATE TABLE questions (id TEXT PRIMARY KEY);
+CREATE TABLE votes (id TEXT PRIMARY KEY);
+CREATE TABLE answers (id TEXT PRIMARY KEY);`); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
+ t.Fatalf("legacy table cleanup is not idempotent: %v", err)
+ }
+ for _, table := range []string{"questions", "votes", "answers"} {
+ var relation sql.NullString
+ if err := conn.QueryRowContext(ctx, "SELECT to_regclass($1)", table).Scan(&relation); err != nil {
+ t.Fatal(err)
+ }
+ if relation.Valid {
+ t.Fatalf("legacy table %q still exists", table)
+ }
+ }
}
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
- exec := &failingMigrationExec{failAt: 6}
+ exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec)
- if err == nil || !strings.Contains(err.Error(), "copy questions") {
- t.Fatalf("error = %v, want copy questions context", err)
+ if err == nil || !strings.Contains(err.Error(), "create post votes") {
+ t.Fatalf("error = %v, want create post votes context", err)
}
}
diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go
index fa362f7..30d545a 100644
--- a/internal/store/postgres_store.go
+++ b/internal/store/postgres_store.go
@@ -109,41 +109,6 @@ func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
return u.SaveProfile(ctx)
}
-func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
- q.db = p.db
- return q.Create(ctx)
-}
-
-func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
- return GetQuestion(ctx, p.db, id, viewerID)
-}
-
-func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
- return ListHunt(ctx, p.db, huntDate, viewerID)
-}
-
-func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
- return ListQuestionsByAuthor(ctx, p.db, authorID)
-}
-
-func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
- return ListQuestionsAnsweredBy(ctx, p.db, adminID)
-}
-
-func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
- q := &RankedQuestion{ID: id, db: p.db}
- return q.Hide(ctx)
-}
-
-func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
- return GetAnswer(ctx, p.db, questionID)
-}
-
-func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
- a.db = p.db
- return a.Upsert(ctx)
-}
-
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
@@ -185,7 +150,3 @@ func (p *Postgres) SetRootPostState(ctx context.Context, id string, state PostSt
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/question.go b/internal/store/question.go
deleted file mode 100644
index c00d50b..0000000
--- a/internal/store/question.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "fmt"
- "strings"
- "time"
-
- "github.com/google/uuid"
-
- "plumber/internal/pacific"
- "plumber/internal/store/sqlc"
-)
-
-// RankedQuestion is a question row with score / vote annotations for lists.
-type RankedQuestion struct {
- ID string
- AuthorID string
- AuthorName string
- Title string
- Body string
- City string
- HuntDate string
- Hidden bool
- CreatedAt string
- Score int
- Answered bool
- UserVote int
- db *sql.DB
-}
-
-// NewQuestion returns a question bound to db (not yet inserted).
-func NewQuestion(db *sql.DB) *RankedQuestion {
- return &RankedQuestion{db: db}
-}
-
-// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty.
-func (q *RankedQuestion) Create(ctx context.Context) error {
- if q == nil || q.db == nil {
- return fmt.Errorf("question: no database")
- }
- q.Title = strings.TrimSpace(q.Title)
- q.Body = strings.TrimSpace(q.Body)
- q.City = strings.TrimSpace(q.City)
- if q.ID == "" {
- q.ID = uuid.NewString()
- }
- if q.HuntDate == "" {
- q.HuntDate = pacific.Today()
- }
- if q.CreatedAt == "" {
- q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
- }
- return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{
- ID: q.ID,
- AuthorID: q.AuthorID,
- Title: q.Title,
- Body: q.Body,
- City: q.City,
- HuntDate: q.HuntDate,
- CreatedAt: q.CreatedAt,
- })
-}
-
-// Hide marks the question hidden.
-func (q *RankedQuestion) Hide(ctx context.Context) error {
- if q == nil || q.db == nil {
- return fmt.Errorf("question: no database")
- }
- if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil {
- return err
- }
- q.Hidden = true
- return nil
-}
-
-func rankedFrom(
- db *sql.DB,
- id, authorID, authorName, title, body, city, huntDate, createdAt string,
- hidden int32, score, answered, userVote int64,
-) RankedQuestion {
- return RankedQuestion{
- ID: id,
- AuthorID: authorID,
- AuthorName: authorName,
- Title: title,
- Body: body,
- City: city,
- HuntDate: huntDate,
- Hidden: hidden != 0,
- CreatedAt: createdAt,
- Score: int(score),
- Answered: answered != 0,
- UserVote: int(userVote),
- db: db,
- }
-}
-
-func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
- rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
- ViewerID: viewerID,
- HuntDate: huntDate,
- RowLimit: HuntListLimit,
- })
- if err != nil {
- return nil, err
- }
- out := make([]RankedQuestion, 0, len(rows))
- for _, r := range rows {
- out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
- }
- return out, nil
-}
-
-func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
- r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{
- ViewerID: viewerID,
- ID: id,
- })
- if err != nil {
- return nil, err
- }
- q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)
- return &q, nil
-}
-
-func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
- rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{
- AuthorID: authorID,
- RowLimit: ProfileListLimit,
- })
- if err != nil {
- return nil, err
- }
- out := make([]RankedQuestion, 0, len(rows))
- for _, r := range rows {
- out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
- }
- return out, nil
-}
-
-func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
- rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{
- AdminID: adminID,
- RowLimit: ProfileListLimit,
- })
- if err != nil {
- return nil, err
- }
- out := make([]RankedQuestion, 0, len(rows))
- for _, r := range rows {
- out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
- }
- return out, nil
-}
diff --git a/internal/store/sqlc/answers.sql.go b/internal/store/sqlc/answers.sql.go
deleted file mode 100644
index 2cdf6dc..0000000
--- a/internal/store/sqlc/answers.sql.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-// source: answers.sql
-
-package sqlc
-
-import (
- "context"
-)
-
-const getAnswer = `-- name: GetAnswer :one
-SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
-FROM answers a
-JOIN users u ON u.id = a.author_id
-WHERE a.question_id = $1
-`
-
-type GetAnswerRow struct {
- QuestionID string
- AuthorID string
- AuthorName string
- Body string
- CreatedAt string
- UpdatedAt string
-}
-
-func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) {
- row := q.db.QueryRowContext(ctx, getAnswer, questionID)
- var i GetAnswerRow
- err := row.Scan(
- &i.QuestionID,
- &i.AuthorID,
- &i.AuthorName,
- &i.Body,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const upsertAnswer = `-- name: UpsertAnswer :exec
-INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
-VALUES ($1, $2, $3, $4, $5)
-ON CONFLICT (question_id) DO UPDATE
-SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at
-`
-
-type UpsertAnswerParams struct {
- QuestionID string
- AuthorID string
- Body string
- CreatedAt string
- UpdatedAt string
-}
-
-func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error {
- _, err := q.db.ExecContext(ctx, upsertAnswer,
- arg.QuestionID,
- arg.AuthorID,
- arg.Body,
- arg.CreatedAt,
- arg.UpdatedAt,
- )
- return err
-}
diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go
index 35568ed..dde5176 100644
--- a/internal/store/sqlc/models.go
+++ b/internal/store/sqlc/models.go
@@ -9,14 +9,6 @@ import (
"time"
)
-type Answer struct {
- QuestionID string
- AuthorID string
- Body string
- CreatedAt string
- UpdatedAt string
-}
-
type Post struct {
ID string
ParentID sql.NullString
@@ -36,17 +28,6 @@ type PostVote struct {
Value int32
}
-type Question struct {
- ID string
- AuthorID string
- Title string
- Body string
- City string
- HuntDate string
- Hidden int32
- CreatedAt string
-}
-
type Session struct {
Token string
Data []byte
@@ -64,9 +45,3 @@ type User struct {
State string
CreatedAt string
}
-
-type Vote struct {
- UserID string
- QuestionID string
- Value int32
-}
diff --git a/internal/store/sqlc/questions.sql.go b/internal/store/sqlc/questions.sql.go
deleted file mode 100644
index 31bcf09..0000000
--- a/internal/store/sqlc/questions.sql.go
+++ /dev/null
@@ -1,316 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-// source: questions.sql
-
-package sqlc
-
-import (
- "context"
-)
-
-const createQuestion = `-- name: CreateQuestion :exec
-INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
-VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
-`
-
-type CreateQuestionParams struct {
- ID string
- AuthorID string
- Title string
- Body string
- City string
- HuntDate string
- CreatedAt string
-}
-
-func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
- _, err := q.db.ExecContext(ctx, createQuestion,
- arg.ID,
- arg.AuthorID,
- arg.Title,
- arg.Body,
- arg.City,
- arg.HuntDate,
- arg.CreatedAt,
- )
- return err
-}
-
-const getQuestion = `-- name: GetQuestion :one
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- COALESCE((
- SELECT votes.value FROM votes
- WHERE votes.user_id = $1 AND votes.question_id = q.id
- ), 0)::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.id = $2
-`
-
-type GetQuestionParams struct {
- ViewerID string
- ID string
-}
-
-type GetQuestionRow struct {
- ID string
- AuthorID string
- AuthorName string
- Title string
- Body string
- City string
- HuntDate string
- Hidden int32
- CreatedAt string
- Score int64
- Answered int64
- UserVote int64
-}
-
-func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
- row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID)
- var i GetQuestionRow
- err := row.Scan(
- &i.ID,
- &i.AuthorID,
- &i.AuthorName,
- &i.Title,
- &i.Body,
- &i.City,
- &i.HuntDate,
- &i.Hidden,
- &i.CreatedAt,
- &i.Score,
- &i.Answered,
- &i.UserVote,
- )
- return i, err
-}
-
-const hideQuestion = `-- name: HideQuestion :exec
-UPDATE questions
-SET hidden = 1
-WHERE id = $1
-`
-
-func (q *Queries) HideQuestion(ctx context.Context, id string) error {
- _, err := q.db.ExecContext(ctx, hideQuestion, id)
- return err
-}
-
-const listHunt = `-- name: ListHunt :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE(SUM(v.value), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- COALESCE((
- SELECT votes.value FROM votes
- WHERE votes.user_id = $1 AND votes.question_id = q.id
- ), 0)::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN votes v ON v.question_id = q.id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.hunt_date = $2 AND q.hidden = 0
-GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
-ORDER BY score DESC, q.created_at ASC
-LIMIT $3
-`
-
-type ListHuntParams struct {
- ViewerID string
- HuntDate string
- RowLimit int32
-}
-
-type ListHuntRow struct {
- ID string
- AuthorID string
- AuthorName string
- Title string
- Body string
- City string
- HuntDate string
- Hidden int32
- CreatedAt string
- Score int64
- Answered int64
- UserVote int64
-}
-
-func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
- rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListHuntRow{}
- for rows.Next() {
- var i ListHuntRow
- if err := rows.Scan(
- &i.ID,
- &i.AuthorID,
- &i.AuthorName,
- &i.Title,
- &i.Body,
- &i.City,
- &i.HuntDate,
- &i.Hidden,
- &i.CreatedAt,
- &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 listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- 1::bigint AS answered,
- 0::bigint AS user_vote
-FROM answers ans
-JOIN questions q ON q.id = ans.question_id
-JOIN users u ON u.id = q.author_id
-WHERE ans.author_id = $1 AND q.hidden = 0
-ORDER BY ans.updated_at DESC
-LIMIT $2
-`
-
-type ListQuestionsAnsweredByParams struct {
- AdminID string
- RowLimit int32
-}
-
-type ListQuestionsAnsweredByRow struct {
- ID string
- AuthorID string
- AuthorName string
- Title string
- Body string
- City string
- HuntDate string
- Hidden int32
- CreatedAt string
- Score int64
- Answered int64
- UserVote int64
-}
-
-func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
- rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListQuestionsAnsweredByRow{}
- for rows.Next() {
- var i ListQuestionsAnsweredByRow
- if err := rows.Scan(
- &i.ID,
- &i.AuthorID,
- &i.AuthorName,
- &i.Title,
- &i.Body,
- &i.City,
- &i.HuntDate,
- &i.Hidden,
- &i.CreatedAt,
- &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 listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
-SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
- COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
- CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
- 0::bigint AS user_vote
-FROM questions q
-JOIN users u ON u.id = q.author_id
-LEFT JOIN answers a ON a.question_id = q.id
-WHERE q.author_id = $1 AND q.hidden = 0
-ORDER BY q.created_at DESC
-LIMIT $2
-`
-
-type ListQuestionsByAuthorParams struct {
- AuthorID string
- RowLimit int32
-}
-
-type ListQuestionsByAuthorRow struct {
- ID string
- AuthorID string
- AuthorName string
- Title string
- Body string
- City string
- HuntDate string
- Hidden int32
- CreatedAt string
- Score int64
- Answered int64
- UserVote int64
-}
-
-func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
- rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListQuestionsByAuthorRow{}
- for rows.Next() {
- var i ListQuestionsByAuthorRow
- if err := rows.Scan(
- &i.ID,
- &i.AuthorID,
- &i.AuthorName,
- &i.Title,
- &i.Body,
- &i.City,
- &i.HuntDate,
- &i.Hidden,
- &i.CreatedAt,
- &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
-}
diff --git a/internal/store/sqlc/votes.sql.go b/internal/store/sqlc/votes.sql.go
deleted file mode 100644
index 503901d..0000000
--- a/internal/store/sqlc/votes.sql.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-// source: votes.sql
-
-package sqlc
-
-import (
- "context"
-)
-
-const deleteVote = `-- name: DeleteVote :exec
-DELETE FROM votes
-WHERE user_id = $1 AND question_id = $2
-`
-
-type DeleteVoteParams struct {
- UserID string
- QuestionID string
-}
-
-func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
- _, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
- return err
-}
-
-const getVote = `-- name: GetVote :one
-SELECT value
-FROM votes
-WHERE user_id = $1 AND question_id = $2
-`
-
-type GetVoteParams struct {
- UserID string
- QuestionID string
-}
-
-func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
- row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
- var value int32
- err := row.Scan(&value)
- return value, err
-}
-
-const questionIsVisible = `-- name: QuestionIsVisible :one
-SELECT EXISTS(
- SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
-)::bool
-`
-
-func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) {
- row := q.db.QueryRowContext(ctx, questionIsVisible, id)
- var column_1 bool
- err := row.Scan(&column_1)
- return column_1, err
-}
-
-const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows
-INSERT INTO votes (user_id, question_id, value)
-SELECT $1, $2, $3
-FROM questions q
-WHERE q.id = $2 AND q.hidden = 0
-ON CONFLICT (user_id, question_id) DO UPDATE
-SET value = excluded.value
-WHERE EXISTS (
- SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
-)
-`
-
-type UpsertVoteOnVisibleParams struct {
- UserID string
- QuestionID string
- Value int32
-}
-
-func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
- result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected()
-}
diff --git a/internal/store/store.go b/internal/store/store.go
index 3423f1d..fa394aa 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -27,16 +27,6 @@ type Store interface {
SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error
- CreateQuestion(ctx context.Context, q *RankedQuestion) error
- GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
- ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
- ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
- ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
- HideQuestion(ctx context.Context, id string) error
-
- 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)
@@ -47,7 +37,4 @@ type Store interface {
ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) 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/internal/store/user.go b/internal/store/user.go
index 02efe70..ece70ce 100644
--- a/internal/store/user.go
+++ b/internal/store/user.go
@@ -9,12 +9,19 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
-// ErrLastAdmin is returned when demoting the only remaining admin.
-var ErrLastAdmin = errors.New("cannot demote the last admin")
+var (
+ // ErrLastAdmin is returned when demoting the only remaining admin.
+ ErrLastAdmin = errors.New("cannot demote the last admin")
+ // ErrDuplicateUsername is returned when inserting an existing username.
+ ErrDuplicateUsername = errors.New("username taken")
+ // ErrDuplicateEmail is returned when inserting or updating an existing email.
+ ErrDuplicateEmail = errors.New("email taken")
+)
// Role is a user privilege level stored in users.role.
type Role string
@@ -51,6 +58,17 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
+func mapUniqueViolation(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
+ return ErrDuplicateEmail
+ }
+ return ErrDuplicateUsername
+ }
+ return err
+}
+
func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
diff --git a/internal/store/vote.go b/internal/store/vote.go
deleted file mode 100644
index 5a3f831..0000000
--- a/internal/store/vote.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "strings"
-
- "github.com/jackc/pgx/v5/pgconn"
-
- "plumber/internal/store/sqlc"
-)
-
-// ErrDuplicateUsername is returned when inserting a username that already exists.
-var ErrDuplicateUsername = errors.New("username taken")
-
-// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
-var ErrDuplicateEmail = errors.New("email taken")
-
-// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
-var ErrHiddenOrMissing = errors.New("question not votable")
-
-// SetVote sets the user's vote to value (1, -1, or 0 to clear) on a visible question.
-func SetVote(ctx context.Context, db *sql.DB, userID, questionID 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.QuestionIsVisible(ctx, questionID)
- if err != nil {
- return err
- }
- if !visible {
- return ErrHiddenOrMissing
- }
- return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
- UserID: userID,
- QuestionID: questionID,
- })
- }
- n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
- UserID: userID,
- QuestionID: questionID,
- Value: int32(value),
- })
- if err != nil {
- return mapUniqueViolation(err)
- }
- if n == 0 {
- return ErrHiddenOrMissing
- }
- return nil
-}
-
-// Vote is kept as an alias for SetVote for callers that still use the old name.
-func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
- return SetVote(ctx, db, userID, questionID, value)
-}
-
-func mapUniqueViolation(err error) error {
- var pgErr *pgconn.PgError
- if errors.As(err, &pgErr) && pgErr.Code == "23505" {
- if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
- return ErrDuplicateEmail
- }
- return ErrDuplicateUsername
- }
- return err
-}
diff --git a/internal/web/server.go b/internal/web/server.go
index 30a7612..0998575 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -196,7 +196,6 @@ func (s *Server) Handler() http.Handler {
r.Post("/submit", s.handleSubmit)
r.Get("/questions/{id}", s.handleQuestion)
r.Post("/questions/{id}/vote", s.handleVote)
- r.Post("/questions/{id}/answer", s.handleAnswer)
r.Post("/questions/{id}/hide", s.handleHide)
r.Post("/posts", s.handleCreatePost)
r.Post("/posts/{id}/edit", s.handleEditPost)
@@ -478,48 +477,6 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
})
}
-func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
- if !s.requireCSRF(w, r) {
- return
- }
- u := currentUser(r)
- if !u.Admin() {
- http.Error(w, "forbidden", http.StatusForbidden)
- return
- }
- id := chi.URLParam(r, "id")
- body := strings.TrimSpace(r.PostFormValue("body"))
- if body == "" {
- http.Error(w, "answer required", http.StatusBadRequest)
- return
- }
- if len(body) > 12000 {
- body = truncateRunes(body, 12000)
- }
- root, err := s.store.GetPost(r.Context(), id)
- if err != nil || root.ParentID != nil || root.PostState == store.PostStateHidden {
- http.NotFound(w, r)
- return
- }
- reply := &store.Post{
- ParentID: &root.ID,
- AuthorID: u.ID,
- Body: body,
- }
- if err := s.store.CreatePost(r.Context(), reply); err != nil {
- http.Error(w, "could not save answer", http.StatusInternalServerError)
- return
- }
- s.notifyPostReply(root, root, reply, u)
- location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
- if isHTMX(r) {
- w.Header().Set("HX-Redirect", location)
- w.WriteHeader(http.StatusSeeOther)
- return
- }
- http.Redirect(w, r, location, http.StatusSeeOther)
-}
-
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index c9d65d2..988ae49 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -19,7 +19,6 @@ import (
"plumber"
"plumber/internal/blob"
- "plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -483,13 +482,12 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
}
}
-func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
- recording := &mail.Recording{}
- srv, mem := newTestServer(t, Config{Mail: recording})
+func TestMutationsVoteHideAndCSRF(t *testing.T) {
+ srv, mem := newTestServer(t, Config{})
h := srv.Handler()
adminName := uniq("admin")
userName := uniq("user")
- admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
+ seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "hunter22")
@@ -562,92 +560,11 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
t.Fatalf("vote not applied: %+v %v", got, err)
}
- // Non-admin answer rejected
rec = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
- for _, c := range userCookies {
- req.AddCookie(c)
- }
+ req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", nil)
h.ServeHTTP(rec, req)
- csrf = csrfFrom(rec.Body.String())
- form = strings.NewReader("_csrf=" + csrf + "&body=Nope")
- req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- for _, c := range userCookies {
- req.AddCookie(c)
- }
- rec = httptest.NewRecorder()
- h.ServeHTTP(rec, req)
- if rec.Code != http.StatusForbidden {
- t.Fatalf("non-admin answer want 403, got %d", rec.Code)
- }
-
- // Admin answer compatibility route creates a reply and redirects the thread.
- rec = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
- for _, c := range adminCookies {
- req.AddCookie(c)
- }
- h.ServeHTTP(rec, req)
- csrf = csrfFrom(rec.Body.String())
- form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.")
- req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("HX-Request", "true")
- for _, c := range adminCookies {
- req.AddCookie(c)
- }
- rec = httptest.NewRecorder()
- h.ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
- }
- thread, err := mem.GetPostThread(context.Background(), q.ID)
- if err != nil || len(thread.Replies) != 1 {
- t.Fatalf("admin reply missing: %+v %v", thread, err)
- }
- adminReply := thread.Replies[0]
- if adminReply.AuthorID != admin.ID || adminReply.Body != "Tighten the nuts." {
- t.Fatalf("unexpected admin reply: %+v", adminReply)
- }
- if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
- t.Fatalf("admin answer redirect = %q", got)
- }
- msgs := waitForMail(t, recording, 1)
- if msg := msgs[0]; msg.ToEmail != user.Email ||
- msg.RootID != q.ID ||
- msg.ReplyID != adminReply.ID ||
- msg.ReplyBody != adminReply.Body {
- t.Fatalf("compatibility reply notification = %+v", msg)
- }
-
- rec = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
- for _, c := range adminCookies {
- req.AddCookie(c)
- }
- h.ServeHTTP(rec, req)
- if body := rec.Body.String(); !strings.Contains(body, "Tighten the nuts.") ||
- !strings.Contains(body, "Edit") ||
- !strings.Contains(body, ">Tighten the nuts.") ||
- !strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
- !strings.Contains(body, `removeAttribute('open')`) ||
- strings.Contains(body, ` '';
-CREATE TABLE IF NOT EXISTS questions (
- id TEXT PRIMARY KEY,
- author_id TEXT NOT NULL REFERENCES users(id),
- title TEXT NOT NULL,
- body TEXT NOT NULL,
- city TEXT NOT NULL DEFAULT '',
- hunt_date TEXT NOT NULL,
- hidden INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_questions_hunt_date ON questions(hunt_date, hidden);
-
-CREATE TABLE IF NOT EXISTS votes (
- user_id TEXT NOT NULL REFERENCES users(id),
- question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
- value INTEGER NOT NULL CHECK (value IN (-1, 1)),
- PRIMARY KEY (user_id, question_id)
-);
-
-CREATE TABLE IF NOT EXISTS answers (
- question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
- author_id TEXT NOT NULL REFERENCES users(id),
- body TEXT NOT NULL,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,