Author SHA1 Message Date
codegirl007 b481dd2925 Replace hidden flag with post state.
CI / test (pull_request) Successful in 6m16s
Store post state as text so Go owns the allowed values and future states such as locked remain representable without a database enum migration.
2026-08-27 00:37:17 -07:00
codegirl007 e918e5bd1d Simplify post listing queries.
CI / test (pull_request) Successful in 6m17s
Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths.
2026-08-27 00:22:07 -07:00
codegirl007 fc8f286c34 Add unified post storage.
CI / test (pull_request) Successful in 6m17s
Provide one creation path for roots and replies, recursive thread loading, body-only updates, root listings, and post voting across PostgreSQL and the in-memory test store.
2026-08-27 00:10:47 -07:00
codegirl007 c5ef15ae1f Add unified posts database groundwork (#2)
Adds self-referencing post and post-vote tables, generated schema models, and an idempotent snapshot migration that preserves the legacy tables during the staged application cutover.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 07:05:24 +00:00
codegirl007 418ef93da5 Add polished answer notifications (#1)
Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 06:55:24 +00:00
34 changed files with 2896 additions and 74 deletions
+5
View File
@@ -14,6 +14,11 @@ SECURE_COOKIE=0
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
# (direct peer must match). Leave unset to ignore XFF and use RemoteAddr.
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
# Resend (answer notifications). Leave RESEND_API_KEY unset to disable.
# RESEND_API_KEY=re_xxxxxxxxx
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
# Public site origin used in email links (required when Resend is enabled):
# APP_BASE_URL=https://askaplumber.example
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
# SPACES_KEY=
# SPACES_SECRET=
+8 -2
View File
@@ -17,6 +17,7 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/store"
"plumber/internal/web"
)
@@ -29,7 +30,11 @@ func main() {
defer sessions.Close()
uploader := blob.FromEnv()
handler := newHandler(db, sessions, uploader)
notifier, err := mail.FromEnv()
if err != nil {
log.Fatalf("mail: %v", err)
}
handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -53,12 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler {
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
Mail: notifier,
})
if err != nil {
log.Fatalf("server: %v", err)
+125
View File
@@ -0,0 +1,125 @@
-- name: CreatePost :exec
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
VALUES (
sqlc.arg(id),
sqlc.arg(parent_id),
sqlc.arg(author_id),
sqlc.arg(title),
sqlc.arg(body),
sqlc.arg(city),
sqlc.arg(post_date),
sqlc.arg(post_state),
sqlc.arg(created_at),
sqlc.arg(updated_at)
);
-- name: GetPost :one
SELECT
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = sqlc.arg(id);
-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT p.*
FROM posts p
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL
SELECT child.*
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
thread.id, thread.parent_id, thread.author_id,
u.name AS author_name, u.role AS author_role,
thread.title, thread.body, thread.city, thread.post_date,
thread.post_state, thread.created_at, thread.updated_at
FROM thread
JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.id;
-- name: UpdatePost :execrows
UPDATE posts
SET
body = sqlc.arg(body),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
SELECT p.*
FROM posts p
WHERE p.parent_id IS NULL
AND p.post_date = sqlc.arg(post_date)
AND p.post_state <> sqlc.arg(hidden_state)
),
thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL
SELECT thread.root_id, child.id, child.author_id
FROM thread
JOIN posts child ON child.parent_id = thread.post_id
),
answered AS (
SELECT DISTINCT thread.root_id
FROM thread
JOIN users u ON u.id = thread.author_id
WHERE u.role = 'admin'
),
scores AS (
SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM roots
JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
)
SELECT
roots.id, roots.parent_id, roots.author_id,
u.name AS author_name, u.role AS author_role,
roots.title, roots.body, roots.city, roots.post_date,
roots.post_state, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score,
(answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
FROM roots
JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_id = roots.id
LEFT JOIN post_votes viewer_vote
ON viewer_vote.user_id = sqlc.arg(viewer_id)
AND viewer_vote.post_id = roots.id
ORDER BY score DESC, roots.created_at, roots.id
LIMIT sqlc.arg(row_limit);
-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
FROM posts
WHERE id = sqlc.arg(id)
AND parent_id IS NULL
AND post_state <> sqlc.arg(hidden_state)
)::bool;
-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = sqlc.arg(user_id)
AND post_id = sqlc.arg(post_id);
-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value)
SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value)
FROM posts p
WHERE p.id = sqlc.arg(post_id)
AND p.parent_id IS NULL
AND p.post_state <> sqlc.arg(hidden_state)
ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value;
+13 -12
View File
@@ -1,24 +1,25 @@
-- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6);
INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, $6, '', '', $7);
-- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at
SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE id = $1;
-- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1;
-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at
SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE (
sqlc.arg(search) = ''
OR username ILIKE '%' || sqlc.arg(search) || '%'
OR name ILIKE '%' || sqlc.arg(search) || '%'
OR email ILIKE '%' || sqlc.arg(search) || '%'
)
AND (
sqlc.arg(cursor_created) = ''
@@ -43,12 +44,12 @@ UPDATE users
SET role = $1
WHERE id = $2;
-- name: UpdateUserState :exec
-- name: UpdateUserProfile :exec
UPDATE users
SET state = $1
WHERE id = $2;
-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
SET state = $1, email = $2
WHERE id = $3;
-- name: UpdateUserProfileAndAvatar :exec
UPDATE users
SET state = $1, email = $2, avatar_url = $3
WHERE id = $4;
+1
View File
@@ -28,6 +28,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/resend/resend-go/v3 v3.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+2
View File
@@ -43,6 +43,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+189
View File
@@ -0,0 +1,189 @@
package mail
import (
"context"
_ "embed"
"fmt"
"html"
"os"
"strings"
"github.com/resend/resend-go/v3"
)
//go:embed mark.png
var markPNG []byte
// QuestionAnswered is the payload for notifying a question author of a reply.
type QuestionAnswered struct {
ToEmail string
ToName string
QuestionID string
QuestionTitle string
AnswerBody string
}
// Notifier sends transactional email about answered questions.
type Notifier interface {
NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
}
// Nop is a no-op Notifier used when Resend is not configured.
type Nop struct{}
func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil }
// Resend sends via the Resend HTTP API.
type Resend struct {
client *resend.Client
from string
baseURL string
}
// FromEnv builds a Notifier from RESEND_* and APP_BASE_URL.
// Returns Nop when RESEND_API_KEY is unset.
func FromEnv() (Notifier, error) {
key := strings.TrimSpace(os.Getenv("RESEND_API_KEY"))
if key == "" {
return Nop{}, nil
}
from := strings.TrimSpace(os.Getenv("RESEND_FROM"))
base := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
if from == "" {
return nil, fmt.Errorf("RESEND_FROM is required when RESEND_API_KEY is set")
}
if base == "" {
return nil, fmt.Errorf("APP_BASE_URL is required when RESEND_API_KEY is set")
}
return &Resend{
client: resend.NewClient(key),
from: from,
baseURL: base,
}, nil
}
func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
if r == nil || r.client == nil {
return nil
}
to := strings.TrimSpace(msg.ToEmail)
if to == "" {
return nil
}
text, htmlBody := questionAnsweredContent(r.baseURL, msg)
params := &resend.SendEmailRequest{
From: r.from,
To: []string{to},
Subject: "Your question was answered",
Text: text,
Html: htmlBody,
Attachments: []*resend.Attachment{{
Content: markPNG,
Filename: "ask-a-plumber-first.png",
ContentType: "image/png",
ContentId: "answer-notification-mark",
}},
}
opts := &resend.SendEmailOptions{
IdempotencyKey: "answer-notify:" + msg.QuestionID,
}
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
return err
}
func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
title := strings.TrimSpace(msg.QuestionTitle)
if title == "" {
title = "your question"
}
text := fmt.Sprintf(
"Hi%s,\n\nYour question %q has an answer from a plumber:\n\n%s\n\nView it here:\n%s\n",
greetingName(msg.ToName),
title,
msg.AnswerBody,
link,
)
htmlBody := strings.NewReplacer(
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
"{{TITLE}}", html.EscapeString(title),
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
"{{LINK}}", html.EscapeString(link),
"{{MARK}}", "cid:answer-notification-mark",
).Replace(questionAnsweredHTML)
return text, htmlBody
}
const questionAnsweredHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>Your question was answered</title>
</head>
<body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{PREHEADER}}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#161719;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:560px;background:#1e2023;border:1px solid #2e3136;border-top:3px solid #e96a26;border-radius:3px;">
<tr>
<td style="padding:24px 28px 20px;border-bottom:1px solid #2e3136;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding-right:12px;vertical-align:middle;">
<img src="{{MARK}}" width="32" height="32" alt="" style="display:block;width:32px;height:32px;border:0;">
</td>
<td style="vertical-align:middle;">
<div style="color:#ecebe7;font-size:14px;font-weight:700;line-height:1.2;letter-spacing:1px;text-transform:uppercase;">Ask a Plumber First</div>
<div style="margin-top:4px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.2;letter-spacing:1.4px;text-transform:uppercase;">Bay Area · Shop Dispatch</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:30px 28px 32px;">
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">Shop response</div>
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">Your question has an answer.</h1>
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, a plumber replied to:</p>
<p style="margin:8px 0 0;color:#ecebe7;font-size:17px;font-weight:600;line-height:1.45;">“{{TITLE}}”</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin-top:24px;background:#161719;border:1px solid #2e3136;border-radius:3px;">
<tr>
<td style="padding:20px 18px;">
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The answer</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{ANSWER}}</div>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
<tr>
<td bgcolor="#e96a26" style="border-radius:3px;">
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the answer&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:18px 28px;border-top:1px solid #2e3136;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.6;letter-spacing:0.4px;">
You received this because you asked a question on Ask a Plumber First.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
func greetingName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
return " " + name
}
+76
View File
@@ -0,0 +1,76 @@
package mail
import (
"bytes"
"strings"
"testing"
)
func TestEmbeddedMarkIsPNG(t *testing.T) {
t.Parallel()
if !bytes.HasPrefix(markPNG, []byte("\x89PNG\r\n\x1a\n")) {
t.Fatal("embedded mark is not PNG data")
}
}
func TestQuestionAnsweredContent(t *testing.T) {
t.Parallel()
text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
ToName: `<Sam & Pat>`,
QuestionID: "question-123",
QuestionTitle: `<b>Leaky sink</b>`,
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
})
for _, want := range []string{
"Ask a Plumber First",
"Shop response",
"cid:answer-notification-mark",
"https://plumber.example/questions/question-123",
"white-space:pre-wrap",
"&lt;Sam &amp; Pat&gt;",
"&lt;b&gt;Leaky sink&lt;/b&gt;",
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
} {
if !strings.Contains(htmlBody, want) {
t.Errorf("HTML missing %q", want)
}
}
for _, unsafe := range []string{
"<Sam & Pat>",
"<b>Leaky sink</b>",
"<script>alert('x')</script>",
} {
if strings.Contains(htmlBody, unsafe) {
t.Errorf("HTML contains unescaped content %q", unsafe)
}
}
if strings.Contains(htmlBody, "{{") {
t.Error("HTML contains an unresolved template token")
}
for _, want := range []string{
`Hi <Sam & Pat>,`,
`Your question "<b>Leaky sink</b>"`,
"Replace the cartridge.\nThen test the handle.",
"https://plumber.example/questions/question-123",
} {
if !strings.Contains(text, want) {
t.Errorf("text missing %q", want)
}
}
}
func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
t.Parallel()
text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
if !strings.Contains(text, `"your question"`) {
t.Errorf("text missing fallback title")
}
if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
!strings.Contains(htmlBody, "“your question”") {
t.Errorf("HTML missing fallback title")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+34
View File
@@ -0,0 +1,34 @@
package mail
import (
"context"
"sync"
)
// Recording is a test Notifier that records calls.
type Recording struct {
mu sync.Mutex
Msgs []QuestionAnswered
}
func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
r.mu.Lock()
defer r.mu.Unlock()
r.Msgs = append(r.Msgs, msg)
return nil
}
func (r *Recording) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.Msgs)
}
// Snapshot returns a copy of recorded messages.
func (r *Recording) Snapshot() []QuestionAnswered {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]QuestionAnswered, len(r.Msgs))
copy(out, r.Msgs)
return out
}
+52
View File
@@ -0,0 +1,52 @@
package store
import (
"fmt"
"net/mail"
"strings"
"unicode/utf8"
)
const (
minEmailLen = 3
maxEmailLen = 254
)
// NormalizeEmail trims and lowercases an address for storage/comparison.
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// ValidateEmail returns a normalized address or an error message suitable for UI.
func ValidateEmail(raw string) (normalized string, errMsg string) {
normalized = NormalizeEmail(raw)
if normalized == "" {
return "", "Email is required."
}
n := utf8.RuneCountInString(normalized)
if n < minEmailLen || len(normalized) > maxEmailLen {
return "", "Enter a valid email address."
}
addr, err := mail.ParseAddress(normalized)
if err != nil || addr.Address != normalized {
return "", "Enter a valid email address."
}
at := strings.LastIndex(normalized, "@")
if at < 1 || at == len(normalized)-1 {
return "", "Enter a valid email address."
}
domain := normalized[at+1:]
if !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return "", "Enter a valid email address."
}
return normalized, ""
}
// MustValidateEmail is like ValidateEmail but returns a Go error.
func MustValidateEmail(raw string) (string, error) {
n, msg := ValidateEmail(raw)
if msg != "" {
return "", fmt.Errorf("%s", msg)
}
return n, nil
}
+21
View File
@@ -0,0 +1,21 @@
package store
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
in, want, err string
}{
{"", "", "Email is required."},
{" Alice@Example.COM ", "alice@example.com", ""},
{"not-an-email", "", "Enter a valid email address."},
{"a@b", "", "Enter a valid email address."},
{"ok@example.com", "ok@example.com", ""},
}
for _, tc := range cases {
got, msg := ValidateEmail(tc.in)
if got != tc.want || msg != tc.err {
t.Fatalf("%q: got (%q, %q) want (%q, %q)", tc.in, got, msg, tc.want, tc.err)
}
}
}
+202
View File
@@ -22,6 +22,8 @@ type Memory struct {
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
}
// NewMemory returns an empty Memory store.
@@ -32,6 +34,8 @@ func NewMemory() *Memory {
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
}
@@ -42,9 +46,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if _, ok := m.byName[u.Username]; ok {
return ErrDuplicateUsername
}
if u.Email != "" {
for _, existing := range m.users {
if existing.Email == u.Email {
return ErrDuplicateEmail
}
}
}
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -179,11 +191,21 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
if !ok {
return sql.ErrNoRows
}
email := NormalizeEmail(u.Email)
if email != "" {
for id, existing := range m.users {
if id != u.ID && existing.Email == email {
return ErrDuplicateEmail
}
}
}
cur.State = strings.TrimSpace(u.State)
cur.Email = email
if u.AvatarURL != "" {
cur.AvatarURL = u.AvatarURL
}
u.State = cur.State
u.Email = cur.Email
u.AvatarURL = cur.AvatarURL
return nil
}
@@ -354,6 +376,186 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
return nil
}
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
if err := preparePost(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
return fmt.Errorf("%w: unknown author", ErrInvalidPost)
}
if _, exists := m.posts[post.ID]; exists {
return fmt.Errorf("%w: duplicate id", ErrInvalidPost)
}
if post.ParentID != nil {
if _, ok := m.posts[*post.ParentID]; !ok {
return fmt.Errorf("%w: unknown parent", ErrInvalidPost)
}
}
cp := clonePost(post)
cp.db = nil
m.posts[cp.ID] = cp
*post = *clonePost(cp)
return nil
}
func (m *Memory) GetPost(_ context.Context, id string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok {
return nil, sql.ErrNoRows
}
return clonePostWithAuthor(post, m.users), nil
}
func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
root, ok := m.posts[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
inThread := map[string]bool{rootID: true}
for changed := true; changed; {
changed = false
for id, post := range m.posts {
if inThread[id] || post.ParentID == nil || !inThread[*post.ParentID] {
continue
}
inThread[id] = true
changed = true
}
}
posts := make([]Post, 0, len(inThread))
for id := range inThread {
posts = append(posts, *clonePostWithAuthor(m.posts[id], m.users))
}
return buildPostTree(posts, rootID)
}
func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
body := strings.TrimSpace(post.Body)
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
if !ok {
return sql.ErrNoRows
}
existing.Body = body
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
}
func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden {
continue
}
cp := clonePostWithAuthor(post, m.users)
for _, value := range m.postVotes[post.ID] {
cp.Score += value
}
cp.UserVote = m.postVotes[post.ID][viewerID]
cp.Answered = m.threadContainsAdminReply(post.ID)
posts = append(posts, *cp)
}
sort.Slice(posts, func(i, j int) bool {
if posts[i].Score != posts[j].Score {
return posts[i].Score > posts[j].Score
}
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt < posts[j].CreatedAt
}
return posts[i].ID < posts[j].ID
})
if len(posts) > HuntListLimit {
posts = posts[:HuntListLimit]
}
return posts, nil
}
func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
post, ok := m.posts[postID]
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
return ErrPostNotVotable
}
if m.postVotes[postID] == nil {
m.postVotes[postID] = map[string]int{}
}
if value == 0 {
delete(m.postVotes[postID], userID)
return nil
}
m.postVotes[postID][userID] = value
return nil
}
func (m *Memory) threadContainsAdminReply(rootID string) bool {
for id, post := range m.posts {
if id == rootID || !m.postIsDescendantOf(post, rootID) {
continue
}
if author := m.users[post.AuthorID]; author != nil && author.Role == RoleAdmin {
return true
}
}
return false
}
func (m *Memory) postIsDescendantOf(post *Post, rootID string) bool {
seen := map[string]bool{}
for post != nil && post.ParentID != nil {
if *post.ParentID == rootID {
return true
}
if seen[*post.ParentID] {
return false
}
seen[*post.ParentID] = true
post = m.posts[*post.ParentID]
}
return false
}
func clonePost(post *Post) *Post {
cp := *post
if post.ParentID != nil {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Replies = nil
return &cp
}
func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
cp := clonePost(post)
if author := users[post.AuthorID]; author != nil {
cp.AuthorName = author.Name
cp.AuthorRole = author.Role
}
return cp
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
+240
View File
@@ -21,6 +21,241 @@ func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
return nil
}
// migrateUserEmail adds email and a partial unique index on lower(email).
func migrateUserEmail(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add column email: %w", err)
}
if _, err := exec.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> ''`); err != nil {
return fmt.Errorf("users_email_lower_uidx: %w", err)
}
return nil
}
// migratePosts creates the unified post model and snapshots legacy content.
// Legacy tables remain in place until the application cutover is complete.
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 (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
post_date TEXT NOT NULL DEFAULT '',
post_state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT posts_shape_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
OR
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
)
)`},
{name: "index post replies", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id)`},
{name: "index root posts", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL`},
{name: "create post votes", sql: `
CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
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 {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostVoteIndex(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id)`); err != nil {
return fmt.Errorf("idx_post_votes_post_id: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"rename post date", `
DO $migration$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'hunt_date'
) AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'post_date'
) THEN
ALTER TABLE posts RENAME COLUMN hunt_date TO post_date;
END IF;
END
$migration$`},
{"drop legacy root date index", `
DROP INDEX IF EXISTS idx_posts_root_hunt`},
{"create root date index", `
DO $migration$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'post_state'
) THEN
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL;
ELSE
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, hidden)
WHERE parent_id IS NULL;
END IF;
END
$migration$`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostState(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS post_state TEXT`); err != nil {
return fmt.Errorf("add post state: %w", err)
}
hasHidden, err := migrationColumnExists(ctx, exec, "posts", "hidden")
if err != nil {
return fmt.Errorf("check hidden column: %w", err)
}
if hasHidden {
if _, err := exec.ExecContext(ctx, `
UPDATE posts
SET post_state = CASE
WHEN hidden = 0 THEN $1
ELSE $2
END`, string(PostStateVisible), string(PostStateHidden)); err != nil {
return fmt.Errorf("copy hidden state: %w", err)
}
}
steps := []struct {
name string
sql string
}{
{"drop root date index", `
DROP INDEX IF EXISTS idx_posts_root_date`},
{"drop legacy post shape constraint", `
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_check`},
{"drop post shape constraint", `
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_shape_check`},
{"drop hidden", `
ALTER TABLE posts DROP COLUMN IF EXISTS hidden`},
{"require post state", `
ALTER TABLE posts ALTER COLUMN post_state SET NOT NULL`},
{"create post shape constraint", `
ALTER TABLE posts
ADD CONSTRAINT posts_shape_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
OR
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
)`},
{"create root date index", `
CREATE INDEX idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migrationColumnExists(
ctx context.Context,
exec execContext,
tableName string,
columnName string,
) (bool, error) {
rows, err := exec.QueryContext(ctx, `
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = $1
AND column_name = $2
)`, tableName, columnName)
if err != nil {
return false, err
}
defer rows.Close()
if !rows.Next() {
return false, rows.Err()
}
var exists bool
if err := rows.Scan(&exists); err != nil {
return false, err
}
return exists, rows.Err()
}
type execContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
@@ -66,6 +301,11 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
return applySchema(ctx, exec, schemaSQL)
}},
{"002_user_profile_columns", migrateUserProfileColumns},
{"003_user_email", migrateUserEmail},
{"004_posts", migratePosts},
{"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
}
for _, m := range migrations {
if applied[m.version] {
+438
View File
@@ -0,0 +1,438 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"github.com/google/uuid"
"plumber/internal/store/sqlc"
)
func TestMigratePostsCopiesLegacyData(t *testing.T) {
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if rawURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
dsn, err := postgresDSN(rawURL)
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
schemaName := "test_posts_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := conn.ExecContext(ctx, "CREATE SCHEMA "+schemaName); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = conn.ExecContext(context.Background(), "SET search_path TO public")
_, _ = conn.ExecContext(context.Background(), "DROP SCHEMA "+schemaName+" CASCADE")
}()
if _, err := conn.ExecContext(ctx, "SET search_path TO "+schemaName); err != nil {
t.Fatal(err)
}
legacySchema := `
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 {
t.Fatal(err)
}
if err := migratePosts(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePosts(ctx, conn); err != nil {
t.Fatalf("migration is not idempotent: %v", err)
}
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index migration is not idempotent: %v", err)
}
var postCount, voteCount, legacyQuestionCount, legacyAnswerCount 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,
)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'post_votes'
AND indexname = 'idx_post_votes_post_id'`).Scan(&postVoteIndexCount); err != nil {
t.Fatal(err)
}
if postVoteIndexCount != 1 {
t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil {
t.Fatal("duplicate user/post vote unexpectedly succeeded")
}
var rootParent sql.NullString
var rootAuthor, title, rootBody, city, postDate, rootState, rootCreated, rootUpdated string
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(
&rootParent,
&rootAuthor,
&title,
&rootBody,
&city,
&postDate,
&rootState,
&rootCreated,
&rootUpdated,
); err != nil {
t.Fatal(err)
}
if rootParent.Valid ||
rootAuthor != "homeowner" ||
title != "Leaky sink" ||
rootBody != "It drips." ||
city != "Oakland" ||
postDate != "2026-08-26" ||
rootState != "visible" ||
rootCreated != "2026-08-26T08:00:00Z" ||
rootUpdated != rootCreated {
t.Fatalf("unexpected root post")
}
var replyParent, replyAuthor, replyBody, replyState, replyCreated, replyUpdated string
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(
&replyParent,
&replyAuthor,
&replyBody,
&replyState,
&replyCreated,
&replyUpdated,
); err != nil {
t.Fatal(err)
}
if replyParent != "question-1" ||
replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." ||
replyState != "visible" ||
replyCreated != "2026-08-26T09:00:00Z" ||
replyUpdated != "2026-08-26T09:05:00Z" {
t.Fatalf("unexpected reply post")
}
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 {
t.Fatal(err)
}
if voteValue != 1 {
t.Fatalf("vote value = %d, want 1", voteValue)
}
if _, err := conn.ExecContext(ctx, `
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', '', '',
'visible', 'now', 'now'
)`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded")
}
queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up",
ParentID: sql.NullString{String: "answer:question-1", Valid: true},
AuthorID: "homeowner",
Body: "It is still dripping.",
PostState: string(PostStateVisible),
CreatedAt: "2026-08-26T10:00:00Z",
UpdatedAt: "2026-08-26T10:00:00Z",
}); err != nil {
t.Fatal(err)
}
thread, err := queries.ListPostThread(ctx, "question-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
thread[0].ID != "question-1" ||
thread[1].ID != "answer:question-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
if err != nil {
t.Fatal(err)
}
if len(nonRootThread) != 0 {
t.Fatalf("non-root thread lookup returned %+v", nonRootThread)
}
if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: "follow-up",
Body: "The drip continues.",
UpdatedAt: "2026-08-26T10:05:00Z",
}); err != nil || n != 1 {
t.Fatalf("update rows=%d error=%v", n, err)
}
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber",
PostID: "question-1",
Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 {
t.Fatalf("vote rows=%d error=%v", n, err)
}
roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{
ViewerID: "plumber",
RowLimit: 100,
PostDate: "2026-08-26",
HiddenState: string(PostStateHidden),
})
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 ||
roots[0].Score != 2 ||
!roots[0].Answered ||
roots[0].UserVote != 1 {
t.Fatalf("root annotations = %+v", roots)
}
if _, err := conn.ExecContext(ctx, `
DROP INDEX idx_posts_root_date;
ALTER TABLE posts RENAME COLUMN post_date TO hunt_date;
CREATE INDEX idx_posts_root_hunt
ON posts(hunt_date, post_state)
WHERE parent_id IS NULL;`); err != nil {
t.Fatal(err)
}
if err := migratePostDate(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostDate(ctx, conn); err != nil {
t.Fatalf("post date migration is not idempotent: %v", err)
}
var postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT
count(*) FILTER (WHERE column_name = 'post_date'),
count(*) FILTER (WHERE column_name = 'hunt_date')
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'`).Scan(&postDateColumnCount, &huntDateColumnCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT
count(*) FILTER (WHERE indexname = 'idx_posts_root_date'),
count(*) FILTER (WHERE indexname = 'idx_posts_root_hunt')
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'posts'`).Scan(&rootDateIndexCount, &legacyIndexCount); err != nil {
t.Fatal(err)
}
var migratedPostDate string
if err := conn.QueryRowContext(ctx, `
SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
t.Fatal(err)
}
if postDateColumnCount != 1 ||
huntDateColumnCount != 0 ||
rootDateIndexCount != 1 ||
legacyIndexCount != 0 ||
migratedPostDate != "2026-08-26" {
t.Fatalf(
"post date migration columns=%d legacy_columns=%d indexes=%d legacy_indexes=%d date=%q",
postDateColumnCount,
huntDateColumnCount,
rootDateIndexCount,
legacyIndexCount,
migratedPostDate,
)
}
if _, err := conn.ExecContext(ctx, `
DROP INDEX idx_posts_root_date;
ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END;
ALTER TABLE posts DROP COLUMN post_state;
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
OR
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
);
CREATE INDEX idx_posts_root_date
ON posts(post_date, hidden)
WHERE parent_id IS NULL;`); err != nil {
t.Fatal(err)
}
if err := migratePostState(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostState(ctx, conn); err != nil {
t.Fatalf("post state migration is not idempotent: %v", err)
}
var postStateColumnCount, hiddenColumnCount int
if err := conn.QueryRowContext(ctx, `
SELECT
count(*) FILTER (WHERE column_name = 'post_state'),
count(*) FILTER (WHERE column_name = 'hidden')
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'`).Scan(&postStateColumnCount, &hiddenColumnCount); err != nil {
t.Fatal(err)
}
var hiddenState, replyStateAfterMigration string
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil {
t.Fatal(err)
}
var postStateDataType string
if err := conn.QueryRowContext(ctx, `
SELECT data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'post_state'`).Scan(&postStateDataType); err != nil {
t.Fatal(err)
}
var stateIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'posts'
AND indexname = 'idx_posts_root_date'
AND indexdef LIKE '%(post_date, post_state)%'`).Scan(&stateIndexCount); err != nil {
t.Fatal(err)
}
if postStateColumnCount != 1 ||
hiddenColumnCount != 0 ||
hiddenState != "hidden" ||
replyStateAfterMigration != "visible" ||
postStateDataType != "text" ||
stateIndexCount != 1 {
t.Fatalf(
"post state migration columns=%d hidden_columns=%d root=%q reply=%q type=%q indexes=%d",
postStateColumnCount,
hiddenColumnCount,
hiddenState,
replyStateAfterMigration,
postStateDataType,
stateIndexCount,
)
}
}
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
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)
}
}
type failingMigrationExec struct {
calls int
failAt int
}
func (f *failingMigrationExec) ExecContext(context.Context, string, ...any) (sql.Result, error) {
f.calls++
if f.calls == f.failAt {
return nil, fmt.Errorf("boom")
}
return nil, nil
}
func (*failingMigrationExec) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
return nil, fmt.Errorf("not implemented")
}
+372
View File
@@ -0,0 +1,372 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/pacific"
"plumber/internal/store/sqlc"
)
var (
ErrInvalidPost = errors.New("invalid post")
ErrPostNotVotable = errors.New("post not votable")
)
type PostState string
const (
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
)
// Post is either a root question (ParentID nil) or a reply to another post.
type Post struct {
ID string
ParentID *string
AuthorID string
AuthorName string
AuthorRole Role
Title string
Body string
City string
PostDate string
PostState PostState
CreatedAt string
UpdatedAt string
Score int
Answered bool
UserVote int
Replies []*Post
db *sql.DB
}
// NewPost returns a post bound to db.
func NewPost(db *sql.DB) *Post {
return &Post{db: db}
}
// Create inserts a root post or reply according to ParentID.
func (p *Post) Create(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
}
if err := preparePost(p); err != nil {
return err
}
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
Title: p.Title,
Body: p.Body,
City: p.City,
PostDate: p.PostDate,
PostState: string(p.PostState),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
return mapPostCreateError(err)
}
// Update changes only the post body and update timestamp.
func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
}
p.Body = strings.TrimSpace(p.Body)
if p.Body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
})
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func preparePost(p *Post) error {
p.ID = strings.TrimSpace(p.ID)
p.AuthorID = strings.TrimSpace(p.AuthorID)
p.Title = strings.TrimSpace(p.Title)
p.Body = strings.TrimSpace(p.Body)
p.City = strings.TrimSpace(p.City)
p.PostDate = strings.TrimSpace(p.PostDate)
if p.PostState == "" {
p.PostState = PostStateVisible
}
switch p.PostState {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
if p.AuthorID == "" {
return fmt.Errorf("%w: author is required", ErrInvalidPost)
}
if p.Body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
if p.ParentID == nil {
if p.Title == "" {
return fmt.Errorf("%w: root title is required", ErrInvalidPost)
}
if p.PostDate == "" {
p.PostDate = pacific.Today()
}
} else {
parentID := strings.TrimSpace(*p.ParentID)
if parentID == "" {
return fmt.Errorf("%w: parent is required", ErrInvalidPost)
}
p.ParentID = &parentID
if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible {
return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
}
}
if p.ID == "" {
p.ID = uuid.NewString()
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if p.CreatedAt == "" {
p.CreatedAt = now
}
if p.UpdatedAt == "" {
p.UpdatedAt = p.CreatedAt
}
return nil
}
func nullableParentID(parentID *string) sql.NullString {
if parentID == nil {
return sql.NullString{}
}
return sql.NullString{String: *parentID, Valid: true}
}
func parentIDFromNull(parentID sql.NullString) *string {
if !parentID.Valid {
return nil
}
id := parentID.String
return &id
}
func mapPostCreateError(err error) error {
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503", "23505", "23514":
return fmt.Errorf("%w: %v", ErrInvalidPost, err)
}
}
return err
}
func postFromValues(
db *sql.DB,
id string,
parentID sql.NullString,
authorID, authorName, authorRole, title, body, city, postDate string,
postState string,
createdAt, updatedAt string,
) Post {
return Post{
ID: id,
ParentID: parentIDFromNull(parentID),
AuthorID: authorID,
AuthorName: authorName,
AuthorRole: Role(authorRole),
Title: title,
Body: body,
City: city,
PostDate: postDate,
PostState: PostState(postState),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
db: db,
}
}
// GetPost returns one post without loading its replies.
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r, err := sqlc.New(db).GetPost(ctx, id)
if err != nil {
return nil, err
}
p := postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
)
return &p, nil
}
// GetPostThread returns a root post with all descendants nested under Replies.
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
posts = append(posts, postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
))
}
return buildPostTree(posts, rootID)
}
func buildPostTree(posts []Post, rootID string) (*Post, error) {
byID := make(map[string]*Post, len(posts))
for i := range posts {
posts[i].Replies = nil
byID[posts[i].ID] = &posts[i]
}
root, ok := byID[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
for i := range posts {
post := &posts[i]
if post.ID == rootID {
continue
}
if post.ParentID == nil {
return nil, fmt.Errorf("post %s is not in thread %s", post.ID, rootID)
}
parent, ok := byID[*post.ParentID]
if !ok {
return nil, fmt.Errorf("post %s has missing parent %s", post.ID, *post.ParentID)
}
parent.Replies = append(parent.Replies, post)
}
var sortReplies func(*Post)
sortReplies = func(post *Post) {
sort.Slice(post.Replies, func(i, j int) bool {
if post.Replies[i].CreatedAt != post.Replies[j].CreatedAt {
return post.Replies[i].CreatedAt < post.Replies[j].CreatedAt
}
return post.Replies[i].ID < post.Replies[j].ID
})
for _, reply := range post.Replies {
sortReplies(reply)
}
}
sortReplies(root)
return root, nil
}
// ListRootPosts returns visible root posts for a post date.
func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{
ViewerID: viewerID,
RowLimit: HuntListLimit,
PostDate: postDate,
HiddenState: string(PostStateHidden),
})
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
post := postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
)
post.Score = int(r.Score)
post.Answered = r.Answered
post.UserVote = int(r.UserVote)
posts = append(posts, post)
}
return posts, nil
}
// SetPostVote sets value to 1, -1, or 0 on a visible root post.
func SetPostVote(ctx context.Context, db *sql.DB, userID, postID 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.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
ID: postID,
HiddenState: string(PostStateHidden),
})
if err != nil {
return err
}
if !visible {
return ErrPostNotVotable
}
return q.DeletePostVote(ctx, sqlc.DeletePostVoteParams{
UserID: userID,
PostID: postID,
})
}
n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: userID,
PostID: postID,
Value: int32(value),
HiddenState: string(PostStateHidden),
})
if err != nil {
return err
}
if n == 0 {
return ErrPostNotVotable
}
return nil
}
+252
View File
@@ -0,0 +1,252 @@
package store
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
)
func TestMapPostCreateError(t *testing.T) {
t.Parallel()
for _, code := range []string{"23503", "23505", "23514"} {
err := mapPostCreateError(&pgconn.PgError{Code: code})
if !errors.Is(err, ErrInvalidPost) {
t.Errorf("code %s error = %v, want ErrInvalidPost", code, err)
}
}
original := &pgconn.PgError{Code: "08006"}
if err := mapPostCreateError(original); !errors.Is(err, original) {
t.Errorf("unexpected database error was replaced: %v", err)
}
}
func TestMemoryPostLifecycle(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
plumber := &User{Username: "plumber", PasswordHash: "hash", Role: RoleAdmin}
voter := &User{Username: "voter", PasswordHash: "hash", Role: RoleUser}
for _, user := range []*User{homeowner, plumber, voter} {
if err := mem.CreateUser(ctx, user); err != nil {
t.Fatal(err)
}
}
root := &Post{
ID: "root",
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "It drips.",
City: "Oakland",
PostDate: "2026-08-26",
CreatedAt: "2026-08-26T08:00:00Z",
}
if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err)
}
if root.PostState != PostStateVisible {
t.Fatalf("default post state = %q, want visible", root.PostState)
}
rootID := root.ID
later := &Post{
ID: "later",
ParentID: &rootID,
AuthorID: plumber.ID,
Body: "Is it a single-handle faucet?",
CreatedAt: "2026-08-26T09:00:00Z",
}
earlier := &Post{
ID: "earlier",
ParentID: &rootID,
AuthorID: plumber.ID,
Body: "Can you share the model number?",
CreatedAt: "2026-08-26T08:30:00Z",
}
if err := mem.CreatePost(ctx, later); err != nil {
t.Fatal(err)
}
if err := mem.CreatePost(ctx, earlier); err != nil {
t.Fatal(err)
}
laterID := later.ID
nested := &Post{
ID: "nested",
ParentID: &laterID,
AuthorID: homeowner.ID,
Body: "Yes, it is.",
CreatedAt: "2026-08-26T09:30:00Z",
}
if err := mem.CreatePost(ctx, nested); err != nil {
t.Fatal(err)
}
thread, err := mem.GetPostThread(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if thread.AuthorName != homeowner.Name || thread.AuthorRole != RoleUser {
t.Fatalf("root author = %q %q", thread.AuthorName, thread.AuthorRole)
}
if len(thread.Replies) != 2 ||
thread.Replies[0].ID != earlier.ID ||
thread.Replies[1].ID != later.ID {
t.Fatalf("root replies are not oldest-first: %+v", thread.Replies)
}
if len(thread.Replies[1].Replies) != 1 || thread.Replies[1].Replies[0].ID != nested.ID {
t.Fatalf("nested reply missing: %+v", thread.Replies[1].Replies)
}
otherParent := earlier.ID
nested.ParentID = &otherParent
nested.AuthorID = voter.ID
nested.Body = "Yes—one handle."
if err := mem.UpdatePost(ctx, nested); err != nil {
t.Fatal(err)
}
saved, err := mem.GetPost(ctx, nested.ID)
if err != nil {
t.Fatal(err)
}
if saved.ParentID == nil || *saved.ParentID != later.ID {
t.Fatalf("update changed parent to %+v", saved.ParentID)
}
if saved.AuthorID != homeowner.ID {
t.Fatalf("update changed author to %q", saved.AuthorID)
}
if saved.Body != "Yes—one handle." || saved.UpdatedAt == saved.CreatedAt {
t.Fatalf("body update not applied: %+v", saved)
}
if err := mem.VotePost(ctx, voter.ID, root.ID, 1); err != nil {
t.Fatal(err)
}
roots, err := mem.ListRootPosts(ctx, root.PostDate, voter.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 || roots[0].ID != root.ID {
t.Fatalf("root list = %+v", roots)
}
if roots[0].Score != 1 || roots[0].UserVote != 1 || !roots[0].Answered {
t.Fatalf("root annotations = %+v", roots[0])
}
if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
t.Fatalf("reply vote error = %v", err)
}
}
func TestMemoryPostValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
post *Post
}{
{
name: "root without title",
post: &Post{AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "empty parent",
post: &Post{ParentID: ptr(""), AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "missing parent",
post: &Post{ParentID: ptr("missing"), AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "reply with root fields",
post: &Post{
ParentID: ptr("missing"),
AuthorID: homeowner.ID,
Title: "Not allowed",
Body: "Body",
},
},
{
name: "invalid post state",
post: &Post{
AuthorID: homeowner.ID,
Title: "Invalid state",
Body: "Body",
PostState: PostState("archived"),
},
},
{
name: "reply with non-visible state",
post: &Post{
ParentID: ptr("missing"),
AuthorID: homeowner.ID,
Body: "Body",
PostState: PostStateLocked,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := mem.CreatePost(ctx, test.post); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("error = %v, want ErrInvalidPost", err)
}
})
}
hidden := &Post{
ID: "hidden",
AuthorID: homeowner.ID,
Title: "Hidden",
Body: "Body",
PostDate: "2026-08-26",
PostState: PostStateHidden,
}
if err := mem.CreatePost(ctx, hidden); err != nil {
t.Fatal(err)
}
if err := mem.VotePost(ctx, homeowner.ID, hidden.ID, 1); !errors.Is(err, ErrPostNotVotable) {
t.Fatalf("hidden root vote error = %v", err)
}
locked := &Post{
ID: "locked",
AuthorID: homeowner.ID,
Title: "Locked",
Body: "Body",
PostDate: "2026-08-26",
PostState: PostStateLocked,
}
if err := mem.CreatePost(ctx, locked); err != nil {
t.Fatal(err)
}
if err := mem.VotePost(ctx, homeowner.ID, locked.ID, 1); err != nil {
t.Fatalf("locked root should remain votable: %v", err)
}
roots, err := mem.ListRootPosts(ctx, locked.PostDate, homeowner.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 || roots[0].ID != locked.ID || roots[0].PostState != PostStateLocked {
t.Fatalf("locked root should remain visible: %+v", roots)
}
if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("missing thread error = %v", err)
}
}
func ptr(value string) *string {
return &value
}
+28
View File
@@ -29,6 +29,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -69,6 +70,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: string(role),
Email: u.Email,
CreatedAt: u.CreatedAt,
}); err != nil {
return mapUniqueViolation(err)
@@ -142,6 +144,32 @@ func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
return a.Upsert(ctx)
}
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
}
func (p *Postgres) GetPost(ctx context.Context, id string) (*Post, error) {
return GetPost(ctx, p.db, id)
}
func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, error) {
return GetPostThread(ctx, p.db, rootID)
}
func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Update(ctx)
}
func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) {
return ListRootPosts(ctx, p.db, postDate, viewerID)
}
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)
}
+21
View File
@@ -5,6 +5,7 @@
package sqlc
import (
"database/sql"
"time"
)
@@ -16,6 +17,25 @@ type Answer struct {
UpdatedAt string
}
type Post struct {
ID string
ParentID sql.NullString
AuthorID string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
type PostVote struct {
UserID string
PostID string
Value int32
}
type Question struct {
ID string
AuthorID string
@@ -39,6 +59,7 @@ type User struct {
Name string
PasswordHash string
Role string
Email string
AvatarUrl string
State string
CreatedAt string
+385
View File
@@ -0,0 +1,385 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: posts.sql
package sqlc
import (
"context"
"database/sql"
)
const createPost = `-- name: CreatePost :exec
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10
)
`
type CreatePostParams struct {
ID string
ParentID sql.NullString
AuthorID string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
_, err := q.db.ExecContext(ctx, createPost,
arg.ID,
arg.ParentID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.PostDate,
arg.PostState,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = $1
AND post_id = $2
`
type DeletePostVoteParams struct {
UserID string
PostID string
}
func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams) error {
_, err := q.db.ExecContext(ctx, deletePostVote, arg.UserID, arg.PostID)
return err
}
const getPost = `-- name: GetPost :one
SELECT
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = $1
`
type GetPostRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
row := q.db.QueryRowContext(ctx, getPost, id)
var i GetPostRow
err := row.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL
UNION ALL
SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.post_state, child.created_at, child.updated_at
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
thread.id, thread.parent_id, thread.author_id,
u.name AS author_name, u.role AS author_role,
thread.title, thread.body, thread.city, thread.post_date,
thread.post_state, thread.created_at, thread.updated_at
FROM thread
JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.id
`
type ListPostThreadRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPostThreadRow, error) {
rows, err := q.db.QueryContext(ctx, listPostThread, rootID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPostThreadRow{}
for rows.Next() {
var i ListPostThreadRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listRootPosts = `-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
FROM posts p
WHERE p.parent_id IS NULL
AND p.post_date = $3
AND p.post_state <> $4
),
thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL
SELECT thread.root_id, child.id, child.author_id
FROM thread
JOIN posts child ON child.parent_id = thread.post_id
),
answered AS (
SELECT DISTINCT thread.root_id
FROM thread
JOIN users u ON u.id = thread.author_id
WHERE u.role = 'admin'
),
scores AS (
SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM roots
JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
)
SELECT
roots.id, roots.parent_id, roots.author_id,
u.name AS author_name, u.role AS author_role,
roots.title, roots.body, roots.city, roots.post_date,
roots.post_state, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score,
(answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
FROM roots
JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_id = roots.id
LEFT JOIN post_votes viewer_vote
ON viewer_vote.user_id = $1
AND viewer_vote.post_id = roots.id
ORDER BY score DESC, roots.created_at, roots.id
LIMIT $2
`
type ListRootPostsParams struct {
ViewerID string
RowLimit int32
PostDate string
HiddenState string
}
type ListRootPostsRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
Score int64
Answered bool
UserVote int64
}
func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPosts,
arg.ViewerID,
arg.RowLimit,
arg.PostDate,
arg.HiddenState,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsRow{}
for rows.Next() {
var i ListRootPostsRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
&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 postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
FROM posts
WHERE id = $1
AND parent_id IS NULL
AND post_state <> $2
)::bool
`
type PostIsVisibleRootParams struct {
ID string
HiddenState string
}
func (q *Queries) PostIsVisibleRoot(ctx context.Context, arg PostIsVisibleRootParams) (bool, error) {
row := q.db.QueryRowContext(ctx, postIsVisibleRoot, arg.ID, arg.HiddenState)
var column_1 bool
err := row.Scan(&column_1)
return column_1, err
}
const updatePost = `-- name: UpdatePost :execrows
UPDATE posts
SET
body = $1,
updated_at = $2
WHERE id = $3
`
type UpdatePostParams struct {
Body string
UpdatedAt string
ID string
}
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3
FROM posts p
WHERE p.id = $2
AND p.parent_id IS NULL
AND p.post_state <> $4
ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value
`
type UpsertPostVoteOnVisibleRootParams struct {
UserID string
PostID string
Value int32
HiddenState string
}
func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot,
arg.UserID,
arg.PostID,
arg.Value,
arg.HiddenState,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+54 -38
View File
@@ -24,8 +24,8 @@ func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
}
const createUser = `-- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6)
INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, $6, '', '', $7)
`
type CreateUserParams struct {
@@ -34,6 +34,7 @@ type CreateUserParams struct {
Name string
PasswordHash string
Role string
Email string
CreatedAt string
}
@@ -44,13 +45,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
arg.Name,
arg.PasswordHash,
arg.Role,
arg.Email,
arg.CreatedAt,
)
return err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at
SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE id = $1
`
@@ -60,6 +62,7 @@ type GetUserByIDRow struct {
Username string
Name string
Role string
Email string
AvatarUrl string
State string
CreatedAt string
@@ -73,6 +76,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
&i.Username,
&i.Name,
&i.Role,
&i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -81,7 +85,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1
`
@@ -91,6 +95,7 @@ type GetUserByUsernameRow struct {
Username string
Name string
Role string
Email string
AvatarUrl string
State string
CreatedAt string
@@ -105,6 +110,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
&i.Username,
&i.Name,
&i.Role,
&i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -127,12 +133,13 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at
SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users
WHERE (
$1 = ''
OR username ILIKE '%' || $1 || '%'
OR name ILIKE '%' || $1 || '%'
OR email ILIKE '%' || $1 || '%'
)
AND (
$2 = ''
@@ -155,6 +162,7 @@ type ListUsersRow struct {
Username string
Name string
Role string
Email string
AvatarUrl string
State string
CreatedAt string
@@ -179,6 +187,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
&i.Username,
&i.Name,
&i.Role,
&i.Email,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
@@ -196,6 +205,46 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
return items, nil
}
const updateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users
SET state = $1, email = $2
WHERE id = $3
`
type UpdateUserProfileParams struct {
State string
Email string
ID string
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfile, arg.State, arg.Email, arg.ID)
return err
}
const updateUserProfileAndAvatar = `-- name: UpdateUserProfileAndAvatar :exec
UPDATE users
SET state = $1, email = $2, avatar_url = $3
WHERE id = $4
`
type UpdateUserProfileAndAvatarParams struct {
State string
Email string
AvatarUrl string
ID string
}
func (q *Queries) UpdateUserProfileAndAvatar(ctx context.Context, arg UpdateUserProfileAndAvatarParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfileAndAvatar,
arg.State,
arg.Email,
arg.AvatarUrl,
arg.ID,
)
return err
}
const updateUserRole = `-- name: UpdateUserRole :execresult
UPDATE users
SET role = $1
@@ -210,36 +259,3 @@ type UpdateUserRoleParams struct {
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
}
const updateUserState = `-- name: UpdateUserState :exec
UPDATE users
SET state = $1
WHERE id = $2
`
type UpdateUserStateParams struct {
State string
ID string
}
func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
_, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
return err
}
const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
WHERE id = $3
`
type UpdateUserStateAndAvatarParams struct {
State string
AvatarUrl string
ID string
}
func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
_, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
return err
}
+7
View File
@@ -37,6 +37,13 @@ type Store interface {
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)
UpdatePost(ctx context.Context, post *Post) error
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, 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
}
+18 -8
View File
@@ -30,6 +30,7 @@ type User struct {
Username string
Name string
Role Role
Email string
AvatarURL string
State string
CreatedAt string
@@ -50,12 +51,13 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
Username: username,
Name: name,
Role: Role(role),
Email: email,
AvatarURL: avatarURL,
State: state,
CreatedAt: createdAt,
@@ -73,6 +75,7 @@ func (u *User) Create(ctx context.Context) error {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = uuid.NewString()
}
@@ -88,6 +91,7 @@ func (u *User) Create(ctx context.Context) error {
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: string(u.Role),
Email: u.Email,
CreatedAt: u.CreatedAt,
}))
}
@@ -149,21 +153,27 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
return nil
}
// SaveProfile writes State and optionally AvatarURL.
// SaveProfile writes Email, State, and optionally AvatarURL.
func (u *User) SaveProfile(ctx context.Context) error {
if u == nil || u.db == nil {
return fmt.Errorf("user: no database")
}
u.State = strings.TrimSpace(u.State)
u.Email = NormalizeEmail(u.Email)
q := sqlc.New(u.db)
if u.AvatarURL == "" {
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
return mapUniqueViolation(q.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{
State: u.State,
Email: u.Email,
ID: u.ID,
}))
}
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
return mapUniqueViolation(q.UpdateUserProfileAndAvatar(ctx, sqlc.UpdateUserProfileAndAvatarParams{
State: u.State,
Email: u.Email,
AvatarUrl: u.AvatarURL,
ID: u.ID,
})
}))
}
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
@@ -187,7 +197,7 @@ func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, strin
}
out := make([]User, 0, len(rows))
for _, r := range rows {
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, "")
out = append(out, *u)
}
var nextCreated, nextID string
@@ -204,7 +214,7 @@ func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
if err != nil {
return nil, err
}
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
}
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
@@ -212,5 +222,5 @@ func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, er
if err != nil {
return nil, err
}
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
}
+7
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn"
@@ -14,6 +15,9 @@ import (
// 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")
@@ -58,6 +62,9 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int)
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
+14 -1
View File
@@ -129,14 +129,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
emailRaw := r.PostFormValue("email")
password := r.PostFormValue("password")
setupSecret := r.PostFormValue("setup_secret")
p := authPage{page: s.basePage(r, "Create account"), Username: username}
p := authPage{page: s.basePage(r, "Create account"), Username: username, Email: strings.TrimSpace(emailRaw)}
if !usernameRe.MatchString(username) {
p.Error = "Username must be 320 letters, numbers, or underscores."
s.exec(w, "register", p)
return
}
email, emailErr := store.ValidateEmail(emailRaw)
if emailErr != "" {
p.Error = emailErr
s.exec(w, "register", p)
return
}
if ok, msg := passwordValid(password); !ok {
p.Error = msg
s.exec(w, "register", p)
@@ -153,6 +160,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
u := &store.User{
Username: username,
Email: email,
PasswordHash: string(hash),
Role: role,
}
@@ -162,6 +170,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
s.exec(w, "register", p)
return
}
if errors.Is(err, store.ErrDuplicateEmail) {
p.Error = "That email is already registered."
s.exec(w, "register", p)
return
}
log.Printf("register create: %v", err)
http.Error(w, "could not create account", http.StatusInternalServerError)
return
+27 -9
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
@@ -27,6 +28,7 @@ type profilePage struct {
UploadsEnabled bool
Error string
StateVal string
EmailVal string
}
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
@@ -35,7 +37,7 @@ func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
return
}
s.renderProfile(w, r, u, "", u.State)
s.renderProfile(w, r, u, "", u.State, u.Email)
}
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
@@ -45,7 +47,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
return
}
if err := r.ParseMultipartForm(3 << 20); err != nil {
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State)
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State, u.Email)
return
}
want := s.sessions.GetString(r.Context(), "csrf")
@@ -57,7 +59,12 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
state := geo.NormalizeState(r.FormValue("state"))
if !geo.ValidState(state) {
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state)
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state, r.FormValue("email"))
return
}
email, emailErr := store.ValidateEmail(r.FormValue("email"))
if emailErr != "" {
s.renderProfile(w, r, u, emailErr, state, r.FormValue("email"))
return
}
@@ -67,16 +74,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
if err == nil {
defer file.Close()
if !s.cfg.Blob.Enabled() {
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state)
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state, email)
return
}
if hdr.Size > 2<<20 {
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state, email)
return
}
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
if prepErr != nil {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state, email)
return
}
prevURL := u.AvatarURL
@@ -88,14 +95,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
Size: int64(len(body)),
})
if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state, email)
return
}
avatarURL = url
u.State = state
u.Email = email
u.AvatarURL = avatarURL
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
_ = s.cfg.Blob.Delete(r.Context(), avatarKey)
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
@@ -106,12 +118,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state)
s.renderProfile(w, r, u, "Could not read avatar file.", state, email)
return
}
u.State = state
u.Email = email
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
@@ -236,7 +253,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
return dst
}
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) {
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var (
questions []store.RankedQuestion
label string
@@ -263,5 +280,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg,
StateVal: stateVal,
EmailVal: emailVal,
})
}
+46
View File
@@ -22,6 +22,7 @@ import (
"plumber/internal/blob"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -34,6 +35,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet
Blob blob.Uploader
Mail mail.Notifier
}
type Server struct {
@@ -82,6 +84,7 @@ type submitPage struct {
type authPage struct {
page
Username string
Email string
Error string
Next string
}
@@ -98,6 +101,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
}
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
@@ -482,6 +488,17 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if len(body) > 12000 {
body = truncateRunes(body, 12000)
}
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.NotFound(w, r)
return
}
_, priorErr := s.store.GetAnswer(r.Context(), id)
wasNew := errors.Is(priorErr, sql.ErrNoRows)
if priorErr != nil && !wasNew {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
ans := &store.Answer{
QuestionID: id,
AuthorID: u.ID,
@@ -491,6 +508,9 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save answer", http.StatusInternalServerError)
return
}
if wasNew {
s.notifyQuestionAnswered(q, body, u.ID)
}
saved, err := s.store.GetAnswer(r.Context(), id)
if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError)
@@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
}
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
if q == nil || s.cfg.Mail == nil {
return
}
author, err := s.store.UserByID(context.Background(), q.AuthorID)
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
return
}
msg := mail.QuestionAnswered{
ToEmail: author.Email,
ToName: author.Name,
QuestionID: q.ID,
QuestionTitle: q.Title,
AnswerBody: answerBody,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
log.Printf("notify answer %s: %v", q.ID, err)
return
}
log.Printf("notify answer %s: accepted", q.ID)
}()
}
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
+155 -1
View File
@@ -19,6 +19,7 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -53,6 +54,7 @@ func seedUser(t *testing.T, st store.Store, username, password string, role stor
}
u := &store.User{
Username: username,
Email: username + "@example.com",
PasswordHash: string(hash),
Role: role,
}
@@ -114,7 +116,7 @@ func registerUser(t *testing.T, h http.Handler, username, password string, setup
pre := rec.Result().Cookies()
preToken := sessionValue(pre)
csrf := csrfFrom(rec.Body.String())
form := "_csrf=" + csrf + "&username=" + username + "&password=" + password
form := "_csrf=" + csrf + "&username=" + username + "&email=" + username + "%40example.com&password=" + password
if len(setupSecret) > 0 && setupSecret[0] != "" {
form += "&setup_secret=" + setupSecret[0]
}
@@ -362,6 +364,7 @@ func TestProfilePageAndState(t *testing.T) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "CA")
_ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -389,6 +392,7 @@ func TestProfilePageAndState(t *testing.T) {
buf.Reset()
w = multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "ZZ")
_ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -449,6 +453,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", hubName+"@example.com")
_ = w.WriteField("state", "OR")
part, err := w.CreateFormFile("avatar", "pic.png")
if err != nil {
@@ -596,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
!strings.Contains(body, "<summary>Edit answer</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="answer-editor" open`) {
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
}
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
t.Fatal(err)
}
// The public answer is visible to its 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(), `class="answer-editor"`) {
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
}
// Hide invalid id
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
@@ -666,6 +693,133 @@ func csrfFrom(html string) string {
return html[:j]
}
func TestRegisterRequiresEmail(t *testing.T) {
srv, _ := newTestServer(t, Config{})
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
csrf := csrfFrom(rec.Body.String())
cookies := rec.Result().Cookies()
form := strings.NewReader("_csrf=" + csrf + "&username=" + uniq("noem") + "&email=bad&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid email") {
t.Fatalf("want email validation error, got %d %s", rec.Code, rec.Body.String())
}
}
func TestAnswerNotifyFirstOnly(t *testing.T) {
recMail := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recMail})
h := srv.Handler()
adminName := uniq("adm")
askName := uniq("ask")
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
asker := seedUser(t, mem, askName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
q := &store.RankedQuestion{
AuthorID: asker.ID,
Title: "Leaky sink",
Body: "Drip",
City: "Oakland",
HuntDate: pacific.Today(),
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
postAnswer := func(body string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=" + body)
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)
}
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("answer %d %s", w.Code, w.Body.String())
}
}
postAnswer("First+reply")
deadline := time.Now().Add(2 * time.Second)
var msgs []mail.QuestionAnswered
for time.Now().Before(deadline) {
msgs = recMail.Snapshot()
if len(msgs) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(msgs) != 1 {
t.Fatalf("first answer notifies once, got %d", len(msgs))
}
if msgs[0].ToEmail != asker.Email || msgs[0].QuestionID != q.ID {
t.Fatalf("unexpected notify: %+v", msgs[0])
}
if msgs[0].AnswerBody != "First reply" {
t.Fatalf("answer body %q", msgs[0].AnswerBody)
}
postAnswer("Edited+reply")
time.Sleep(50 * time.Millisecond)
if recMail.Len() != 1 {
t.Fatalf("edit must not notify again, got %d", recMail.Len())
}
// Author without email is skipped
recMail2 := &mail.Recording{}
srv2, mem2 := newTestServer(t, Config{Mail: recMail2})
h2 := srv2.Handler()
admin2 := seedUser(t, mem2, uniq("adm2"), "hunter22", store.RoleAdmin)
noMail := &store.User{Username: uniq("silent"), PasswordHash: admin.PasswordHash, Role: store.RoleUser, Email: ""}
hash, _ := bcrypt.GenerateFromPassword([]byte("hunter22"), bcrypt.MinCost)
noMail.PasswordHash = string(hash)
if err := mem2.CreateUser(context.Background(), noMail); err != nil {
t.Fatal(err)
}
q2 := &store.RankedQuestion{AuthorID: noMail.ID, Title: "Quiet", Body: "x", HuntDate: pacific.Today()}
if err := mem2.CreateQuestion(context.Background(), q2); err != nil {
t.Fatal(err)
}
cookies := loginUser(t, h2, admin2.Username, "hunter22")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q2.ID, nil)
for _, c := range cookies {
req.AddCookie(c)
}
h2.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=Hello")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q2.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
w = httptest.NewRecorder()
h2.ServeHTTP(w, req)
time.Sleep(50 * time.Millisecond)
if recMail2.Len() != 0 {
t.Fatalf("empty email must skip notify, got %d", recMail2.Len())
}
}
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
// This must go through Handler() so middleware ordering bugs are caught.
+40
View File
@@ -4,11 +4,16 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
email TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> '';
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
@@ -37,6 +42,41 @@ CREATE TABLE IF NOT EXISTS answers (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
post_date TEXT NOT NULL DEFAULT '',
post_state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT posts_shape_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
OR
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
)
);
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id);
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
+38
View File
@@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
font-size: 0.72rem;
}
.answer-editor {
margin-top: 16px;
border-top: 1px solid var(--line);
}
.answer-editor summary {
display: flex;
width: fit-content;
min-height: 44px;
align-items: center;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: underline;
cursor: pointer;
list-style: none;
}
.answer-editor summary::-webkit-details-marker { display: none; }
.answer-editor summary:hover,
.answer-editor[open] summary { color: var(--signal); }
.answer-editor summary:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.answer-editor .answer-form { margin-top: 4px; }
.answer-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.answer-form-actions .btn { flex: 1 1 10rem; }
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
.auth-wrap {
+16
View File
@@ -5,6 +5,22 @@
<h2>Answer</h2>
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
<p class="answer-body">{{.Answer.Body}}</p>
{{if isAdmin .User}}
<details class="answer-editor">
<summary>Edit answer</summary>
<form class="answer-form" method="post" action="/questions/{{.Answer.QuestionID}}/answer"
hx-post="/questions/{{.Answer.QuestionID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="answer-body">Edit answer</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{.Answer.Body}}</textarea>
<div class="answer-form-actions">
<button type="submit" class="btn btn-primary">Save answer</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
{{else}}
<p class="waiting">No answer yet. Check back after the hunt.</p>
{{end}}
+4
View File
@@ -25,6 +25,10 @@
</div>
</div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required maxlength="254" autocomplete="email" value="{{.EmailVal}}">
<p class="hint">Required. Well email you when a plumber answers your question.</p>
<label for="state">State</label>
<select id="state" name="state">
<option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option>
+3 -3
View File
@@ -23,12 +23,12 @@
</div>
</article>
{{template "answer" .}}
{{if isAdmin .User}}
{{if and (isAdmin .User) (not .Answer)}}
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea>
<label for="answer-body">Write the answer</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
<button type="submit" class="btn btn-primary">Save answer</button>
</form>
{{end}}
+3
View File
@@ -9,6 +9,9 @@
<label for="username">Username</label>
<input id="username" name="username" type="text" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
<p class="hint">320 letters, numbers, or underscores.</p>
<label for="email">Email</label>
<input id="email" name="email" type="email" required maxlength="254" autocomplete="email" value="{{.Email}}">
<p class="hint">Well email you when a plumber answers your question.</p>
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="8" maxlength="72" autocomplete="new-password">
<p class="hint">At least 8 characters (max 72 bytes).</p>