Author SHA1 Message Date
codegirl007 c6f80e243d Merge pull request 'App' (#9) from app into master
CI / test (push) Successful in 6m17s
Reviewed-on: #9
2026-08-27 16:25:35 +00:00
codegirl007 8a2c7de8ca Add post permalinks (#8)
CI / test (pull_request) Successful in 6m17s
Reviewed-on: #8
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:21:38 +00:00
codegirl007 f420f888af 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: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +00:00
codegirl007 f0591ccea3 Generalize post reply notifications (#6)
## Summary
- Notify the direct parent post author for replies throughout nested conversations
- Skip root creation, self-replies, edits, disabled mail, and recipients without email
- Link directly to each reply and use per-reply Resend idempotency

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:03:33 +00:00
codegirl007 f33a0739c2 Initial Ask a Plumber First server 2026-08-22 12:26:32 -07:00
26 changed files with 282 additions and 1401 deletions
-11
View File
@@ -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;
-62
View File
@@ -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);
-24
View File
@@ -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
);
-63
View File
@@ -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
}
+4 -199
View File
@@ -10,20 +10,15 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"plumber/internal/pacific"
) )
// Memory is an in-process Store for tests. // Memory is an in-process Store for tests.
type Memory struct { type Memory struct {
mu sync.Mutex mu sync.Mutex
users map[string]*User // id -> user users map[string]*User // id -> user
byName map[string]string // username -> id byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question posts map[string]*Post // id -> post
answers map[string]*Answer // questionID -> answer postVotes map[string]map[string]int // postID -> userID -> value
votes map[string]map[string]int // questionID -> userID -> value
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
} }
// NewMemory returns an empty Memory store. // NewMemory returns an empty Memory store.
@@ -31,9 +26,6 @@ func NewMemory() *Memory {
return &Memory{ return &Memory{
users: map[string]*User{}, users: map[string]*User{},
byName: map[string]string{}, byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{}, posts: map[string]*Post{},
postVotes: map[string]map[string]int{}, postVotes: map[string]map[string]int{},
} }
@@ -210,172 +202,6 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
return nil 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 { func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil { if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost) return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -637,24 +463,3 @@ func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
} }
return cp 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
}
+20 -28
View File
@@ -35,13 +35,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
return nil return nil
} }
// migratePosts creates the unified post model and snapshots legacy content. // migratePosts creates the unified post model.
// Legacy tables remain in place until the application cutover is complete.
func migratePosts(ctx context.Context, exec execContext) error { func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
sql string sql string
args []any
}{ }{
{name: "create posts", sql: ` {name: "create posts", sql: `
CREATE TABLE IF NOT EXISTS posts ( 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)), value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id) 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 { 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) return fmt.Errorf("%s: %w", step.name, err)
} }
} }
@@ -129,6 +103,23 @@ CREATE INDEX IF NOT EXISTS idx_posts_author_created
return nil 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 { func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
@@ -319,6 +310,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"006_post_date", migratePostDate}, {"006_post_date", migratePostDate},
{"007_post_state", migratePostState}, {"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex}, {"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+75 -75
View File
@@ -13,7 +13,7 @@ import (
"plumber/internal/store/sqlc" "plumber/internal/store/sqlc"
) )
func TestMigratePostsCopiesLegacyData(t *testing.T) { func TestPostMigrationsAndQueries(t *testing.T) {
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if rawURL == "" { if rawURL == "" {
t.Skip("TEST_DATABASE_URL is not set") t.Skip("TEST_DATABASE_URL is not set")
@@ -47,47 +47,13 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
legacySchema := ` baseSchema := `
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
role 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 { if err := applySchema(ctx, conn, baseSchema); 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 {
t.Fatal(err) t.Fatal(err)
} }
@@ -103,28 +69,40 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePostVoteIndex(ctx, conn); err != nil { if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index migration is not idempotent: %v", err) 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 { if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil { if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil { if postCount != 2 || voteCount != 1 {
t.Fatal(err) t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
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,
)
} }
var postVoteIndexCount int var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, ` if err := conn.QueryRowContext(ctx, `
@@ -152,7 +130,7 @@ WHERE schemaname = current_schema()
} }
if _, err := conn.ExecContext(ctx, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value) 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") t.Fatal("duplicate user/post vote unexpectedly succeeded")
} }
@@ -161,7 +139,7 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
if err := conn.QueryRowContext(ctx, ` if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
FROM posts FROM posts
WHERE id = 'question-1'`).Scan( WHERE id = 'root-1'`).Scan(
&rootParent, &rootParent,
&rootAuthor, &rootAuthor,
&title, &title,
@@ -190,7 +168,7 @@ WHERE id = 'question-1'`).Scan(
if err := conn.QueryRowContext(ctx, ` if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, body, post_state, created_at, updated_at SELECT parent_id, author_id, body, post_state, created_at, updated_at
FROM posts FROM posts
WHERE id = 'answer:question-1'`).Scan( WHERE id = 'reply-1'`).Scan(
&replyParent, &replyParent,
&replyAuthor, &replyAuthor,
&replyBody, &replyBody,
@@ -200,7 +178,7 @@ WHERE id = 'answer:question-1'`).Scan(
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if replyParent != "question-1" || if replyParent != "root-1" ||
replyAuthor != "plumber" || replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." || replyBody != "Replace the cartridge." ||
replyState != "visible" || replyState != "visible" ||
@@ -212,7 +190,7 @@ WHERE id = 'answer:question-1'`).Scan(
var voteValue int var voteValue int
if err := conn.QueryRowContext(ctx, ` if err := conn.QueryRowContext(ctx, `
SELECT value FROM post_votes 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) t.Fatal(err)
} }
if voteValue != 1 { if voteValue != 1 {
@@ -223,7 +201,7 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err !
INSERT INTO posts ( INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES ( ) VALUES (
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 'invalid-reply', 'root-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'visible', 'now', 'now' 'visible', 'now', 'now'
)`); err == nil { )`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded") t.Fatal("reply with root-only title unexpectedly succeeded")
@@ -232,7 +210,7 @@ INSERT INTO posts (
queries := sqlc.New(conn) queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{ if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up", ID: "follow-up",
ParentID: sql.NullString{String: "answer:question-1", Valid: true}, ParentID: sql.NullString{String: "reply-1", Valid: true},
AuthorID: "homeowner", AuthorID: "homeowner",
Body: "It is still dripping.", Body: "It is still dripping.",
PostState: string(PostStateVisible), PostState: string(PostStateVisible),
@@ -241,17 +219,17 @@ INSERT INTO posts (
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
thread, err := queries.ListPostThread(ctx, "question-1") thread, err := queries.ListPostThread(ctx, "root-1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(thread) != 3 || if len(thread) != 3 ||
thread[0].ID != "question-1" || thread[0].ID != "root-1" ||
thread[1].ID != "answer:question-1" || thread[1].ID != "reply-1" ||
thread[2].ID != "follow-up" { thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread) t.Fatalf("recursive thread = %+v", thread)
} }
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1") nonRootThread, err := queries.ListPostThread(ctx, "reply-1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -267,7 +245,7 @@ INSERT INTO posts (
} }
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber", UserID: "plumber",
PostID: "question-1", PostID: "root-1",
Value: 1, Value: 1,
HiddenState: string(PostStateHidden), HiddenState: string(PostStateHidden),
}); err != nil || n != 1 { }); err != nil || n != 1 {
@@ -290,7 +268,7 @@ INSERT INTO posts (
} }
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{ summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber", ViewerID: "plumber",
RootID: "question-1", RootID: "root-1",
}) })
if err != nil || summary.Score != 2 || summary.UserVote != 1 { if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err) t.Fatalf("root vote summary = %+v, %v", summary, err)
@@ -300,7 +278,7 @@ INSERT INTO posts (
HiddenState: string(PostStateHidden), HiddenState: string(PostStateHidden),
RowLimit: 50, 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) t.Fatalf("roots by author = %+v, %v", byAuthor, err)
} }
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{ answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
@@ -308,14 +286,14 @@ INSERT INTO posts (
AdminID: "plumber", AdminID: "plumber",
RowLimit: 50, 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) t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
} }
for _, state := range []PostState{PostStateLocked, PostStateVisible} { for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{ n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state), PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z", UpdatedAt: "2026-08-26T10:10:00Z",
ID: "question-1", ID: "root-1",
}) })
if err != nil || n != 1 { if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err) t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
@@ -358,7 +336,7 @@ WHERE schemaname = current_schema()
} }
var migratedPostDate string var migratedPostDate string
if err := conn.QueryRowContext(ctx, ` 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) t.Fatal(err)
} }
if postDateColumnCount != 1 || 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; DROP INDEX idx_posts_root_date;
ALTER TABLE posts DROP CONSTRAINT posts_shape_check; ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0; 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 DROP COLUMN post_state;
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK ( ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '') (parent_id IS NULL AND title <> '' AND post_date <> '')
@@ -411,11 +389,11 @@ WHERE table_schema = current_schema()
} }
var hiddenState, replyStateAfterMigration string var hiddenState, replyStateAfterMigration string
if err := conn.QueryRowContext(ctx, ` 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) t.Fatal(err)
} }
if err := conn.QueryRowContext(ctx, ` 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) t.Fatal(err)
} }
var postStateDataType string var postStateDataType string
@@ -453,15 +431,37 @@ WHERE schemaname = current_schema()
stateIndexCount, 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) { func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel() t.Parallel()
exec := &failingMigrationExec{failAt: 6} exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec) err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "copy questions") { if err == nil || !strings.Contains(err.Error(), "create post votes") {
t.Fatalf("error = %v, want copy questions context", err) t.Fatalf("error = %v, want create post votes context", err)
} }
} }
-39
View File
@@ -109,41 +109,6 @@ func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
return u.SaveProfile(ctx) 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 { func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db post.db = p.db
return post.Create(ctx) 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 { func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value) 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)
}
-156
View File
@@ -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
}
-66
View File
@@ -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
}
-25
View File
@@ -9,14 +9,6 @@ import (
"time" "time"
) )
type Answer struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
type Post struct { type Post struct {
ID string ID string
ParentID sql.NullString ParentID sql.NullString
@@ -36,17 +28,6 @@ type PostVote struct {
Value int32 Value int32
} }
type Question struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
}
type Session struct { type Session struct {
Token string Token string
Data []byte Data []byte
@@ -64,9 +45,3 @@ type User struct {
State string State string
CreatedAt string CreatedAt string
} }
type Vote struct {
UserID string
QuestionID string
Value int32
}
-316
View File
@@ -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
}
-82
View File
@@ -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()
}
-13
View File
@@ -27,16 +27,6 @@ type Store interface {
SetUserRole(ctx context.Context, id string, role Role) error SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) 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 CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error) GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID 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) ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) error SetRootPostState(ctx context.Context, id string, state PostState) error
VotePost(ctx context.Context, userID, postID string, value int) 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
} }
+20 -2
View File
@@ -9,12 +9,19 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc" "plumber/internal/store/sqlc"
) )
// ErrLastAdmin is returned when demoting the only remaining admin. var (
var ErrLastAdmin = errors.New("cannot demote the last admin") // 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. // Role is a user privilege level stored in users.role.
type Role string type Role string
@@ -51,6 +58,17 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s)) 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 { func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{ return &User{
ID: id, ID: id,
-71
View File
@@ -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
}
+10 -4
View File
@@ -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( func (s *Server) notifyPostReply(
parent *store.Post, parent *store.Post,
root *store.Post, root *store.Post,
@@ -104,13 +105,19 @@ func (s *Server) notifyPostReply(
root == nil || root == nil ||
reply == nil || reply == nil ||
replyAuthor == nil || replyAuthor == nil ||
s.cfg.Mail == nil || s.cfg.Mail == nil {
parent.AuthorID == replyAuthor.ID {
return return
} }
if _, disabled := s.cfg.Mail.(mail.Nop); disabled { if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return return
} }
recipientID := parent.AuthorID
if replyAuthor.Admin() {
recipientID = root.AuthorID
}
if recipientID == replyAuthor.ID {
return
}
msg := mail.PostReply{ msg := mail.PostReply{
RootID: root.ID, RootID: root.ID,
RootTitle: root.Title, RootTitle: root.Title,
@@ -118,7 +125,6 @@ func (s *Server) notifyPostReply(
ReplyBody: reply.Body, ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name, ReplyAuthorName: replyAuthor.Name,
} }
recipientID := parent.AuthorID
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
+25 -1
View File
@@ -334,6 +334,22 @@ func TestPostReplyNotifications(t *testing.T) {
t.Fatalf("homeowner reply notification = %+v", msg) 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{ rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF}, "_csrf": {homeownerCSRF},
"parent_id": {root.ID}, "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()) t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
} }
time.Sleep(50 * time.Millisecond) 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()) t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
} }
} }
@@ -442,8 +458,13 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
"Shop response", "Shop response",
"Edited", "Edited",
`action="/posts"`, `action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`action="/posts/` + root.ID + `/edit"`, `action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
`href="/questions/` + root.ID + `#post-` + homeownerReply.ID + `"`,
`href="/questions/` + root.ID + `#post-` + adminReply.ID + `"`,
`>The model number is 123A.</textarea>`, `>The model number is 123A.</textarea>`,
`removeAttribute('open')`, `removeAttribute('open')`,
} { } {
@@ -451,6 +472,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Fatalf("question page missing %q: %s", want, body) t.Fatalf("question page missing %q: %s", want, body)
} }
} }
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) { if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body) t.Fatalf("homeowner can edit admin reply: %s", body)
} }
-43
View File
@@ -196,7 +196,6 @@ func (s *Server) Handler() http.Handler {
r.Post("/submit", s.handleSubmit) r.Post("/submit", s.handleSubmit)
r.Get("/questions/{id}", s.handleQuestion) r.Get("/questions/{id}", s.handleQuestion)
r.Post("/questions/{id}/vote", s.handleVote) r.Post("/questions/{id}/vote", s.handleVote)
r.Post("/questions/{id}/answer", s.handleAnswer)
r.Post("/questions/{id}/hide", s.handleHide) r.Post("/questions/{id}/hide", s.handleHide)
r.Post("/posts", s.handleCreatePost) r.Post("/posts", s.handleCreatePost)
r.Post("/posts/{id}/edit", s.handleEditPost) 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) { func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) { if !s.requireCSRF(w, r) {
return return
+16 -89
View File
@@ -19,7 +19,6 @@ import (
"plumber" "plumber"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific" "plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -169,6 +168,16 @@ func TestRegisterLoginAsk(t *testing.T) {
if rec.Code != 200 { if rec.Code != 200 {
t.Fatalf("submit form %d", rec.Code) 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()) csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland")
req = httptest.NewRequest(http.MethodPost, "/submit", form) req = httptest.NewRequest(http.MethodPost, "/submit", form)
@@ -483,13 +492,12 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
} }
} }
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { func TestMutationsVoteHideAndCSRF(t *testing.T) {
recording := &mail.Recording{} srv, mem := newTestServer(t, Config{})
srv, mem := newTestServer(t, Config{Mail: recording})
h := srv.Handler() h := srv.Handler()
adminName := uniq("admin") adminName := uniq("admin")
userName := uniq("user") 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) user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22") adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "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) t.Fatalf("vote not applied: %+v %v", got, err)
} }
// Non-admin answer rejected
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String()) if rec.Code != http.StatusNotFound {
form = strings.NewReader("_csrf=" + csrf + "&body=Nope") t.Fatalf("removed answer endpoint want 404, got %d", rec.Code)
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, "<summary>Edit</summary>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="post-composer" open`) {
t.Fatalf("admin reply editor is not collapsed and populated: %s", body)
}
// The public reply is visible to the root author, but editing remains admin-only.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), `/posts/`+adminReply.ID+`/edit`) {
t.Fatalf("question author can edit admin reply: %s", rec.Body.String())
} }
// Hide invalid id // Hide invalid id
-28
View File
@@ -14,34 +14,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email)) ON users (lower(email))
WHERE email <> ''; WHERE email <> '';
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 ( CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE, parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
+40
View File
@@ -41,6 +41,31 @@ body {
background-size: 100% 100%, 48px 48px; 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; } img, svg { display: block; }
a { a {
@@ -253,6 +278,12 @@ a {
.btn-primary:hover { filter: brightness(1.08); } .btn-primary:hover { filter: brightness(1.08); }
.btn:disabled {
cursor: wait;
opacity: 0.65;
filter: none;
}
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--ink); color: var(--ink);
@@ -718,6 +749,11 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; } .post-form-actions .btn { flex: 1 1 10rem; }
.post-permalink {
display: inline-flex;
align-items: center;
}
.post-hide { .post-hide {
margin: 0; margin: 0;
} }
@@ -763,6 +799,10 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.btn-primary:hover { filter: none; } .btn-primary:hover { filter: none; }
.submit-progress-bar {
width: 100%;
animation: none;
}
} }
.admin-users { margin-top: 20px; overflow-x: auto; } .admin-users { margin-top: 20px; overflow-x: auto; }
+58
View File
@@ -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;
}
});
})();
+5
View File
@@ -11,8 +11,13 @@
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head> </head>
<body> <body>
<div id="submit-progress" class="submit-progress" role="progressbar"
aria-label="Posting" aria-valuetext="Posting" hidden>
<span class="submit-progress-bar"></span>
</div>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
<header class="top"> <header class="top">
<div class="top-inner"> <div class="top-inner">
+6 -2
View File
@@ -3,13 +3,14 @@
{{if canReply .User .Root}} {{if canReply .User .Root}}
<details class="post-composer"> <details class="post-composer">
<summary>Reply</summary> <summary>Reply</summary>
<form class="post-form" method="post" action="/posts"> <form class="post-form" method="post" action="/posts"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="parent_id" value="{{.Post.ID}}"> <input type="hidden" name="parent_id" value="{{.Post.ID}}">
<label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label> <label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label>
<textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea> <textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea>
<div class="post-form-actions"> <div class="post-form-actions">
<button type="submit" class="btn btn-primary">Post reply</button> <button type="submit" class="btn btn-primary" data-submit-button>Post reply</button>
<button type="reset" class="btn btn-ghost" <button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button> onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div> </div>
@@ -32,6 +33,9 @@
</form> </form>
</details> </details>
{{end}} {{end}}
<a class="linkish post-permalink"
href="/questions/{{.Root.ID}}#post-{{.Post.ID}}"
aria-label="Permanent link to post by {{.Post.AuthorName}}">Permalink</a>
{{if and (not .Post.ParentID) (isAdmin .User)}} {{if and (not .Post.ParentID) (isAdmin .User)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide"> <form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
+3 -2
View File
@@ -4,7 +4,8 @@
<h1>Ask a question</h1> <h1>Ask a question</h1>
<p class="lede">It lands on todays hunt (Pacific time). People vote; the ranking resets at midnight PT.</p> <p class="lede">It lands on todays hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}} {{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
<form class="ask" method="post" action="/submit"> <form class="ask" method="post" action="/submit"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="title">Title</label> <label for="title">Title</label>
<input id="title" name="title" type="text" required maxlength="120" value="{{.TitleVal}}" placeholder="Water heater popping after showers"> <input id="title" name="title" type="text" required maxlength="120" value="{{.TitleVal}}" placeholder="Water heater popping after showers">
@@ -12,7 +13,7 @@
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea> <textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
<label for="city">City <span class="optional">(optional)</span></label> <label for="city">City <span class="optional">(optional)</span></label>
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland"> <input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
<button type="submit" class="btn btn-primary">Submit to todays hunt</button> <button type="submit" class="btn btn-primary" data-submit-button>Submit to todays hunt</button>
</form> </form>
</main> </main>
{{template "footer" .}} {{template "footer" .}}