From df17e0e6f3c9c509c089f5c9e86a8d75f3971c65 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 09:08:40 -0700 Subject: [PATCH] Remove legacy question storage --- db/queries/answers.sql | 11 - db/queries/questions.sql | 62 ------ db/queries/votes.sql | 24 -- internal/store/answer.go | 63 ------ internal/store/memory.go | 203 +---------------- internal/store/migrate.go | 48 ++-- internal/store/migrate_posts_test.go | 150 ++++++------- internal/store/postgres_store.go | 39 ---- internal/store/question.go | 156 ------------- internal/store/sqlc/answers.sql.go | 66 ------ internal/store/sqlc/models.go | 25 --- internal/store/sqlc/questions.sql.go | 316 --------------------------- internal/store/sqlc/votes.sql.go | 82 ------- internal/store/store.go | 13 -- internal/store/user.go | 22 +- internal/store/vote.go | 71 ------ internal/web/server.go | 43 ---- internal/web/server_test.go | 95 +------- schema.sql | 28 --- 19 files changed, 125 insertions(+), 1392 deletions(-) delete mode 100644 db/queries/answers.sql delete mode 100644 db/queries/questions.sql delete mode 100644 db/queries/votes.sql delete mode 100644 internal/store/answer.go delete mode 100644 internal/store/question.go delete mode 100644 internal/store/sqlc/answers.sql.go delete mode 100644 internal/store/sqlc/questions.sql.go delete mode 100644 internal/store/sqlc/votes.sql.go delete mode 100644 internal/store/vote.go 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,