From f420f888af4a5910406135da8721f3683e6acf54 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 16:17:57 +0000 Subject: [PATCH] Remove legacy question storage (#7) Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting. Reviewed-on: https://git.codegirl007.com/codegirl007/plumber/pulls/7 Co-authored-by: codegirl-007 --- 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/posts.go | 14 +- internal/web/posts_test.go | 20 +- internal/web/server.go | 43 ---- internal/web/server_test.go | 105 ++------- schema.sql | 28 --- static/app.css | 35 +++ static/app.js | 58 +++++ templates/base.html | 5 + templates/partials/_post.html | 5 +- templates/submit.html | 5 +- 26 files changed, 268 insertions(+), 1401 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 create mode 100644 static/app.js 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/posts.go b/internal/web/posts.go index b448853..35d2f2e 100644 --- a/internal/web/posts.go +++ b/internal/web/posts.go @@ -93,7 +93,8 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { ) } -// notifyPostReply asynchronously emails the direct parent post's author. +// notifyPostReply emails the root homeowner for admin replies and the direct +// parent author for homeowner replies. func (s *Server) notifyPostReply( parent *store.Post, root *store.Post, @@ -104,13 +105,19 @@ func (s *Server) notifyPostReply( root == nil || reply == nil || replyAuthor == nil || - s.cfg.Mail == nil || - parent.AuthorID == replyAuthor.ID { + s.cfg.Mail == nil { return } if _, disabled := s.cfg.Mail.(mail.Nop); disabled { return } + recipientID := parent.AuthorID + if replyAuthor.Admin() { + recipientID = root.AuthorID + } + if recipientID == replyAuthor.ID { + return + } msg := mail.PostReply{ RootID: root.ID, RootTitle: root.Title, @@ -118,7 +125,6 @@ func (s *Server) notifyPostReply( ReplyBody: reply.Body, ReplyAuthorName: replyAuthor.Name, } - recipientID := parent.AuthorID go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go index 0ea4829..1b63317 100644 --- a/internal/web/posts_test.go +++ b/internal/web/posts_test.go @@ -334,6 +334,22 @@ func TestPostReplyNotifications(t *testing.T) { t.Fatalf("homeowner reply notification = %+v", msg) } + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {adminCSRF}, + "parent_id": {adminReply.ID}, + "body": {"One more plumber detail."}, + }, adminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("nested admin reply status = %d: %s", rec.Code, rec.Body.String()) + } + msgs = waitForMail(t, recording, 3) + if msg := msgs[2]; msg.ToEmail != homeowner.Email || + msg.RootID != root.ID || + msg.ReplyBody != "One more plumber detail." || + msg.ReplyAuthorName != admin.Name { + t.Fatalf("nested admin reply notification = %+v", msg) + } + rec = postForm(handler, "/posts", url.Values{ "_csrf": {homeownerCSRF}, "parent_id": {root.ID}, @@ -376,7 +392,7 @@ func TestPostReplyNotifications(t *testing.T) { t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String()) } time.Sleep(50 * time.Millisecond) - if recording.Len() != 2 { + if recording.Len() != 3 { t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot()) } } @@ -442,6 +458,8 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { "Shop response", "Edited", `action="/posts"`, + `data-submit-once`, + `data-submit-button`, `action="/posts/` + root.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`, `>The model number is 123A.`, 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..5a9393d 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" ) @@ -169,6 +168,16 @@ func TestRegisterLoginAsk(t *testing.T) { if rec.Code != 200 { t.Fatalf("submit form %d", rec.Code) } + for _, want := range []string{ + `src="/static/app.js"`, + `id="submit-progress"`, + `data-submit-once`, + `data-submit-button`, + } { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("submit form missing %q: %s", want, rec.Body.String()) + } + } csrf := csrfFrom(rec.Body.String()) form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") req = httptest.NewRequest(http.MethodPost, "/submit", form) @@ -483,13 +492,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 +570,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, diff --git a/static/app.css b/static/app.css index 38898e0..c5fa9f7 100644 --- a/static/app.css +++ b/static/app.css @@ -41,6 +41,31 @@ body { background-size: 100% 100%, 48px 48px; } +.submit-progress { + position: fixed; + inset: 0 0 auto; + z-index: 100; + height: 3px; + overflow: hidden; + pointer-events: none; +} + +.submit-progress[hidden] { display: none; } + +.submit-progress-bar { + display: block; + width: 35%; + height: 100%; + background: var(--signal); + box-shadow: 0 0 12px var(--signal); + animation: submit-progress 900ms ease-in-out infinite; +} + +@keyframes submit-progress { + from { transform: translateX(-100%); } + to { transform: translateX(300%); } +} + img, svg { display: block; } a { @@ -253,6 +278,12 @@ a { .btn-primary:hover { filter: brightness(1.08); } +.btn:disabled { + cursor: wait; + opacity: 0.65; + filter: none; +} + .btn-ghost { background: transparent; color: var(--ink); @@ -763,6 +794,10 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: @media (prefers-reduced-motion: reduce) { .btn-primary:hover { filter: none; } + .submit-progress-bar { + width: 100%; + animation: none; + } } .admin-users { margin-top: 20px; overflow-x: auto; } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1ec32a4 --- /dev/null +++ b/static/app.js @@ -0,0 +1,58 @@ +(() => { + const formSelector = "form[data-submit-once]"; + + function progressIndicator() { + return document.getElementById("submit-progress"); + } + + function resetForm(form) { + form.removeAttribute("aria-busy"); + delete form.dataset.submitting; + + const button = form.querySelector("[data-submit-button]"); + if (!button) { + return; + } + button.disabled = false; + button.removeAttribute("aria-disabled"); + if (button.dataset.idleLabel) { + button.textContent = button.dataset.idleLabel; + delete button.dataset.idleLabel; + } + } + + document.addEventListener("submit", (event) => { + const form = event.target.closest(formSelector); + if (!form) { + return; + } + if (form.dataset.submitting === "true") { + event.preventDefault(); + return; + } + + form.dataset.submitting = "true"; + form.setAttribute("aria-busy", "true"); + + const button = event.submitter || form.querySelector("[data-submit-button]"); + if (button) { + button.dataset.idleLabel = button.textContent; + button.textContent = form.dataset.submittingLabel || "Posting…"; + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + } + + const progress = progressIndicator(); + if (progress) { + progress.hidden = false; + } + }); + + window.addEventListener("pageshow", () => { + document.querySelectorAll(formSelector).forEach(resetForm); + const progress = progressIndicator(); + if (progress) { + progress.hidden = true; + } + }); +})(); diff --git a/templates/base.html b/templates/base.html index f7a32d0..591038f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,8 +11,13 @@ + +
diff --git a/templates/partials/_post.html b/templates/partials/_post.html index d01569f..95e1c26 100644 --- a/templates/partials/_post.html +++ b/templates/partials/_post.html @@ -3,13 +3,14 @@ {{if canReply .User .Root}}
Reply -
+
- +
diff --git a/templates/submit.html b/templates/submit.html index ce8668f..bfb2175 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -4,7 +4,8 @@

Ask a question

It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.

{{if .Error}}{{end}} - + @@ -12,7 +13,7 @@ - +
{{template "footer" .}}