Author SHA1 Message Date
codegirl007 e09b7a7040 Add post permalinks
CI / test (pull_request) Successful in 6m17s
2026-08-27 09:20:23 -07:00
codegirl007 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +00:00
codegirl007 f0591ccea3 Generalize post reply notifications (#6)
## Summary
- Notify the direct parent post author for replies throughout nested conversations
- Skip root creation, self-replies, edits, disabled mail, and recipients without email
- Link directly to each reply and use per-reply Resend idempotency

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:03:33 +00:00
codegirl007 7412069ca6 Add nested posts UI (#5)
## Summary
- Cut hunt, question, submission, voting, hiding, and profile flows over to unified posts
- Render nested replies with permission-aware inline Reply/Edit controls and edited markers
- Add post profile queries and the author index migration they depend on

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 15:53:01 +00:00
codegirl007 4d994d5300 Add post mutation permissions (#4)
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 14:28:21 +00:00
codegirl007 e86c2072ea Add unified post storage (#3)
Adds one post creation path for roots and replies, recursive thread loading, body-only updates, root listings, and root-only voting across PostgreSQL and the in-memory store.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 07:43:25 +00: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
40 changed files with 3874 additions and 1550 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ SECURE_COOKIE=0
# 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
# APP_BASE_URL=https://www.askaplumberfirst.com
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
# SPACES_KEY=
# SPACES_SECRET=
-11
View File
@@ -1,11 +0,0 @@
-- name: UpsertAnswer :exec
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at;
-- name: GetAnswer :one
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1;
+183
View File
@@ -0,0 +1,183 @@
-- 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: GetRootPostVoteSummary :one
SELECT
COALESCE(SUM(value), 0)::bigint AS score,
COALESCE(
MAX(value) FILTER (WHERE user_id = sqlc.arg(viewer_id)),
0
)::bigint AS user_vote
FROM post_votes
WHERE post_id = sqlc.arg(root_id);
-- name: UpdatePost :execrows
UPDATE posts
SET
body = sqlc.arg(body),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: UpdateRootPostState :execrows
UPDATE posts
SET
post_state = sqlc.arg(post_state),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND parent_id IS NULL;
-- 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: ListRootPostsByAuthor :many
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.parent_id IS NULL
AND p.author_id = sqlc.arg(author_id)
AND p.post_state <> sqlc.arg(hidden_state)
ORDER BY p.created_at DESC, p.id DESC
LIMIT sqlc.arg(row_limit);
-- name: ListRootPostsAnsweredBy :many
WITH RECURSIVE ancestors AS (
SELECT p.id, p.parent_id
FROM posts p
WHERE p.author_id = sqlc.arg(admin_id)
AND p.parent_id IS NOT NULL
UNION
SELECT parent.id, parent.parent_id
FROM posts parent
JOIN ancestors child ON child.parent_id = parent.id
)
SELECT DISTINCT
root.id, root.parent_id, root.author_id,
u.name AS author_name, u.role AS author_role,
root.title, root.body, root.city, root.post_date,
root.post_state, root.created_at, root.updated_at
FROM posts root
JOIN ancestors ON ancestors.id = root.id
JOIN users u ON u.id = root.author_id
WHERE root.parent_id IS NULL
AND root.post_state <> sqlc.arg(hidden_state)
ORDER BY root.created_at DESC, root.id DESC
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;
-62
View File
@@ -1,62 +0,0 @@
-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7);
-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1;
-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC
LIMIT sqlc.arg(row_limit);
-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = sqlc.arg(id);
-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
ORDER BY q.created_at DESC
LIMIT sqlc.arg(row_limit);
-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
ORDER BY ans.updated_at DESC
LIMIT sqlc.arg(row_limit);
-24
View File
@@ -1,24 +0,0 @@
-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2;
-- name: QuestionIsVisible :one
SELECT EXISTS(
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
)::bool;
-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2;
-- name: UpsertVoteOnVisible :execrows
INSERT INTO votes (user_id, question_id, value)
SELECT $1, $2, $3
FROM questions q
WHERE q.id = $2 AND q.hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
WHERE EXISTS (
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
);
+51 -35
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"html"
"net/url"
"os"
"strings"
@@ -14,24 +15,26 @@ import (
//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
// PostReply is the payload for notifying a post author of a direct reply.
type PostReply struct {
ToEmail string
ToName string
RootID string
RootTitle string
ReplyID string
ReplyBody string
ReplyAuthorName string
}
// Notifier sends transactional email about answered questions.
// Notifier sends transactional email about post replies.
type Notifier interface {
NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
NotifyPostReply(ctx context.Context, msg PostReply) 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 }
func (Nop) NotifyPostReply(context.Context, PostReply) error { return nil }
// Resend sends via the Resend HTTP API.
type Resend struct {
@@ -62,7 +65,7 @@ func FromEnv() (Notifier, error) {
}, nil
}
func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
func (r *Resend) NotifyPostReply(ctx context.Context, msg PostReply) error {
if r == nil || r.client == nil {
return nil
}
@@ -70,59 +73,72 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
if to == "" {
return nil
}
text, htmlBody := questionAnsweredContent(r.baseURL, msg)
text, htmlBody := postReplyContent(r.baseURL, msg)
params := &resend.SendEmailRequest{
From: r.from,
To: []string{to},
Subject: "Your question was answered",
Subject: "New reply to your post",
Text: text,
Html: htmlBody,
Attachments: []*resend.Attachment{{
Content: markPNG,
Filename: "ask-a-plumber-first.png",
ContentType: "image/png",
ContentId: "answer-notification-mark",
ContentId: "reply-notification-mark",
}},
}
opts := &resend.SendEmailOptions{
IdempotencyKey: "answer-notify:" + msg.QuestionID,
IdempotencyKey: "post-reply:" + msg.ReplyID,
}
_, 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"
func postReplyContent(baseURL string, msg PostReply) (string, string) {
link := strings.TrimRight(baseURL, "/") +
"/questions/" + url.PathEscape(msg.RootID) +
"#post-" + url.PathEscape(msg.ReplyID)
title := replyRootTitle(msg.RootTitle)
author := strings.TrimSpace(msg.ReplyAuthorName)
if author == "" {
author = "Someone"
}
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",
"Hi%s,\n\n%s replied in %q:\n\n%s\n\nView the reply:\n%s\n",
greetingName(msg.ToName),
author,
title,
msg.AnswerBody,
msg.ReplyBody,
link,
)
htmlBody := strings.NewReplacer(
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
"{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."),
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
"{{TITLE}}", html.EscapeString(title),
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
"{{AUTHOR}}", html.EscapeString(author),
"{{REPLY}}", html.EscapeString(msg.ReplyBody),
"{{LINK}}", html.EscapeString(link),
"{{MARK}}", "cid:answer-notification-mark",
).Replace(questionAnsweredHTML)
"{{MARK}}", "cid:reply-notification-mark",
).Replace(postReplyHTML)
return text, htmlBody
}
const questionAnsweredHTML = `<!doctype html>
func replyRootTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "your conversation"
}
return title
}
const postReplyHTML = `<!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>
<title>New reply to your conversation</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>
@@ -147,22 +163,22 @@ const questionAnsweredHTML = `<!doctype html>
</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>
<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;">New reply</div>
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">The conversation has a new reply.</h1>
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, {{AUTHOR}} replied in:</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>
<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 reply</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</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>
<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 reply&nbsp;&rarr;</a>
</td>
</tr>
</table>
@@ -170,7 +186,7 @@ const questionAnsweredHTML = `<!doctype html>
</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.
You received this because someone replied to your post on Ask a Plumber First.
</td>
</tr>
</table>
+21 -17
View File
@@ -14,24 +14,27 @@ func TestEmbeddedMarkIsPNG(t *testing.T) {
}
}
func TestQuestionAnsweredContent(t *testing.T) {
func TestPostReplyContent(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>",
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{
ToName: `<Sam & Pat>`,
RootID: "question-123",
RootTitle: `<b>Leaky sink</b>`,
ReplyID: "reply-456",
ReplyBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
ReplyAuthorName: `<Jo & Co>`,
})
for _, want := range []string{
"Ask a Plumber First",
"Shop response",
"cid:answer-notification-mark",
"https://plumber.example/questions/question-123",
"New reply",
"cid:reply-notification-mark",
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456",
"white-space:pre-wrap",
"&lt;Sam &amp; Pat&gt;",
"&lt;b&gt;Leaky sink&lt;/b&gt;",
"&lt;Jo &amp; Co&gt;",
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
} {
if !strings.Contains(htmlBody, want) {
@@ -41,6 +44,7 @@ func TestQuestionAnsweredContent(t *testing.T) {
for _, unsafe := range []string{
"<Sam & Pat>",
"<b>Leaky sink</b>",
"<Jo & Co>",
"<script>alert('x')</script>",
} {
if strings.Contains(htmlBody, unsafe) {
@@ -52,9 +56,9 @@ func TestQuestionAnsweredContent(t *testing.T) {
}
for _, want := range []string{
`Hi <Sam & Pat>,`,
`Your question "<b>Leaky sink</b>"`,
`<Jo & Co> replied in "<b>Leaky sink</b>"`,
"Replace the cartridge.\nThen test the handle.",
"https://plumber.example/questions/question-123",
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456",
} {
if !strings.Contains(text, want) {
t.Errorf("text missing %q", want)
@@ -62,15 +66,15 @@ func TestQuestionAnsweredContent(t *testing.T) {
}
}
func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
func TestPostReplyContentUsesFallbacks(t *testing.T) {
t.Parallel()
text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
if !strings.Contains(text, `"your question"`) {
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com", PostReply{})
if !strings.Contains(text, `Someone replied in "your conversation"`) {
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")
if !strings.Contains(htmlBody, "Someone replied in:</p>") ||
!strings.Contains(htmlBody, "“your conversation”") {
t.Errorf("HTML missing fallbacks")
}
}
+4 -4
View File
@@ -8,10 +8,10 @@ import (
// Recording is a test Notifier that records calls.
type Recording struct {
mu sync.Mutex
Msgs []QuestionAnswered
Msgs []PostReply
}
func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
func (r *Recording) NotifyPostReply(_ context.Context, msg PostReply) error {
r.mu.Lock()
defer r.mu.Unlock()
r.Msgs = append(r.Msgs, msg)
@@ -25,10 +25,10 @@ func (r *Recording) Len() int {
}
// Snapshot returns a copy of recorded messages.
func (r *Recording) Snapshot() []QuestionAnswered {
func (r *Recording) Snapshot() []PostReply {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]QuestionAnswered, len(r.Msgs))
out := make([]PostReply, len(r.Msgs))
copy(out, r.Msgs)
return out
}
-63
View File
@@ -1,63 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// Answer is an admin reply to a question.
type Answer struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
db *sql.DB
}
// NewAnswer returns an Answer bound to db.
func NewAnswer(db *sql.DB) *Answer {
return &Answer{db: db}
}
// Upsert inserts or updates the answer for QuestionID.
func (a *Answer) Upsert(ctx context.Context) error {
if a == nil || a.db == nil {
return fmt.Errorf("answer: no database")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{
QuestionID: a.QuestionID,
AuthorID: a.AuthorID,
Body: a.Body,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
r, err := sqlc.New(db).GetAnswer(ctx, questionID)
if err != nil {
return nil, err
}
return &Answer{
QuestionID: r.QuestionID,
AuthorID: r.AuthorID,
AuthorName: r.AuthorName,
Body: r.Body,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
db: db,
}, nil
}
+206 -135
View File
@@ -10,18 +10,15 @@ import (
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
type Memory struct {
mu sync.Mutex
users map[string]*User // id -> user
byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
users map[string]*User // id -> user
byName map[string]string // username -> id
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
@@ -29,9 +26,8 @@ func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
}
@@ -206,189 +202,264 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
return nil
}
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
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()
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
if _, ok := m.users[post.AuthorID]; !ok {
return fmt.Errorf("%w: unknown author", ErrInvalidPost)
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
if _, exists := m.posts[post.ID]; exists {
return fmt.Errorf("%w: duplicate id", ErrInvalidPost)
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
if post.ParentID != nil {
if _, ok := m.posts[*post.ParentID]; !ok {
return fmt.Errorf("%w: unknown parent", ErrInvalidPost)
}
}
author, ok := m.users[q.AuthorID]
if !ok {
return fmt.Errorf("unknown author")
}
cp := *q
cp.AuthorName = author.Name
cp := clonePost(post)
cp.db = nil
m.questions[cp.ID] = &cp
*q = cp
m.posts[cp.ID] = cp
*post = *clonePost(cp)
return nil
}
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
out := *q
score := 0
userVote := 0
if votes, ok := m.votes[q.ID]; ok {
for uid, v := range votes {
score += v
if uid == viewerID {
userVote = v
}
}
}
_, answered := m.answers[q.ID]
out.Score = score
out.Answered = answered
out.UserVote = userVote
out.db = nil
return out
}
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
func (m *Memory) GetPost(_ context.Context, id string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
post, ok := m.posts[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.annotate(q, viewerID)
return &out, nil
return clonePostWithAuthor(post, m.users), nil
}
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.annotate(q, viewerID))
root, ok := m.posts[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
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
}
return out[i].CreatedAt < out[j].CreatedAt
})
if len(out) > HuntListLimit {
out = out[:HuntListLimit]
}
return out, nil
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) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
func (m *Memory) GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error) {
root, err := m.GetPostThread(ctx, rootID)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.annotate(q, ""))
for _, value := range m.postVotes[rootID] {
root.Score += value
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
root.UserVote = m.postVotes[rootID][viewerID]
root.Answered = m.threadContainsAdminReply(rootID)
return root, nil
}
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
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()
out := make([]RankedQuestion, 0)
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.annotate(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
}
func (m *Memory) HideQuestion(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
existing, ok := m.posts[post.ID]
if !ok {
return sql.ErrNoRows
}
q.Hidden = true
existing.Body = body
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
}
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.answers[questionID]
if !ok {
return nil, sql.ErrNoRows
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)
}
cp := *a
if u, ok := m.users[a.AuthorID]; ok {
cp.AuthorName = u.Name
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 &cp, nil
return posts, nil
}
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
func (m *Memory) ListRootPostsByAuthor(_ context.Context, authorID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[a.QuestionID]; !ok {
return fmt.Errorf("unknown question")
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil ||
post.AuthorID != authorID ||
post.PostState == PostStateHidden {
continue
}
posts = append(posts, *clonePostWithAuthor(post, m.users))
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[a.QuestionID]; ok {
a.CreatedAt = existing.CreatedAt
} else if a.CreatedAt == "" {
a.CreatedAt = now
return sortProfilePosts(posts), nil
}
func (m *Memory) ListRootPostsAnsweredBy(_ context.Context, adminID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, root := range m.posts {
if root.ParentID != nil || root.PostState == PostStateHidden {
continue
}
participated := false
for _, post := range m.posts {
if post.AuthorID == adminID && m.postIsDescendantOf(post, root.ID) {
participated = true
break
}
}
if participated {
posts = append(posts, *clonePostWithAuthor(root, m.users))
}
}
a.UpdatedAt = now
cp := *a
cp.db = nil
m.answers[a.QuestionID] = &cp
*a = cp
return sortProfilePosts(posts), nil
}
func (m *Memory) SetRootPostState(_ context.Context, id string, state PostState) error {
switch state {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok || post.ParentID != nil {
return sql.ErrNoRows
}
post.PostState = state
post.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
return nil
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
func sortProfilePosts(posts []Post) []Post {
sort.Slice(posts, func(i, j int) bool {
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt > posts[j].CreatedAt
}
return posts[i].ID > posts[j].ID
})
if len(posts) > ProfileListLimit {
posts = posts[:ProfileListLimit]
}
return posts
}
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")
}
q, ok := m.questions[questionID]
if !ok || q.Hidden {
return ErrHiddenOrMissing
post, ok := m.posts[postID]
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
return ErrPostNotVotable
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
if m.postVotes[postID] == nil {
m.postVotes[postID] = map[string]int{}
}
if value == 0 {
delete(m.votes[questionID], userID)
delete(m.postVotes[postID], userID)
return nil
}
m.votes[questionID][userID] = value
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
}
+230
View File
@@ -35,6 +35,230 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
return nil
}
// migratePosts creates the unified post model.
func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{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 post authors", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_author_created
ON posts(author_id, created_at DESC, id DESC)`},
{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)
)`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); 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 migratePostAuthorIndex(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_posts_author_created
ON posts(author_id, created_at DESC, id DESC)`); err != nil {
return fmt.Errorf("idx_posts_author_created: %w", err)
}
return nil
}
func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"drop legacy answers", `DROP TABLE IF EXISTS answers`},
{"drop legacy votes", `DROP TABLE IF EXISTS votes`},
{"drop legacy questions", `DROP TABLE IF EXISTS questions`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
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)
@@ -81,6 +305,12 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
}},
{"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},
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
}
for _, m := range migrations {
if applied[m.version] {
+483
View File
@@ -0,0 +1,483 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"github.com/google/uuid"
"plumber/internal/store/sqlc"
)
func TestPostMigrationsAndQueries(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)
}
baseSchema := `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL
);`
if err := applySchema(ctx, conn, baseSchema); 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)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatalf("post author index migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES
(
'root-1', NULL, 'homeowner', 'Leaky sink', 'It drips.', 'Oakland',
'2026-08-26', 'visible', '2026-08-26T08:00:00Z', '2026-08-26T08:00:00Z'
),
(
'reply-1', 'root-1', 'plumber', '', 'Replace the cartridge.', '', '',
'visible', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z'
);
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'root-1', 1);`); err != nil {
t.Fatal(err)
}
var postCount, voteCount 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 postCount != 2 || voteCount != 1 {
t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
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)
}
var postAuthorIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'posts'
AND indexname = 'idx_posts_author_created'`).Scan(&postAuthorIndexCount); err != nil {
t.Fatal(err)
}
if postAuthorIndexCount != 1 {
t.Fatalf("post author index count = %d, want 1", postAuthorIndexCount)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'root-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 = 'root-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 = 'reply-1'`).Scan(
&replyParent,
&replyAuthor,
&replyBody,
&replyState,
&replyCreated,
&replyUpdated,
); err != nil {
t.Fatal(err)
}
if replyParent != "root-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 = 'root-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', 'root-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: "reply-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, "root-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
thread[0].ID != "root-1" ||
thread[1].ID != "reply-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
nonRootThread, err := queries.ListPostThread(ctx, "reply-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: "root-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)
}
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber",
RootID: "root-1",
})
if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err)
}
byAuthor, err := queries.ListRootPostsByAuthor(ctx, sqlc.ListRootPostsByAuthorParams{
AuthorID: "homeowner",
HiddenState: string(PostStateHidden),
RowLimit: 50,
})
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "root-1" {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
HiddenState: string(PostStateHidden),
AdminID: "plumber",
RowLimit: 50,
})
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "root-1" {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z",
ID: "root-1",
})
if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
}
}
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 = 'root-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 = 'root-1' THEN 1 ELSE 0 END;
ALTER TABLE posts DROP COLUMN post_state;
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
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 = 'root-1'`).Scan(&hiddenState); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'reply-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,
)
}
if _, err := conn.ExecContext(ctx, `
CREATE TABLE questions (id TEXT PRIMARY KEY);
CREATE TABLE votes (id TEXT PRIMARY KEY);
CREATE TABLE answers (id TEXT PRIMARY KEY);`); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatalf("legacy table cleanup is not idempotent: %v", err)
}
for _, table := range []string{"questions", "votes", "answers"} {
var relation sql.NullString
if err := conn.QueryRowContext(ctx, "SELECT to_regclass($1)", table).Scan(&relation); err != nil {
t.Fatal(err)
}
if relation.Valid {
t.Fatalf("legacy table %q still exists", table)
}
}
}
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "create post votes") {
t.Fatalf("error = %v, want create post votes 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")
}
+488
View File
@@ -0,0 +1,488 @@
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)
}
// GetPostThreadForViewer includes root voting and answered annotations.
func GetPostThreadForViewer(
ctx context.Context,
db *sql.DB,
rootID string,
viewerID string,
) (*Post, error) {
root, err := GetPostThread(ctx, db, rootID)
if err != nil {
return nil, err
}
summary, err := sqlc.New(db).GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: viewerID,
RootID: rootID,
})
if err != nil {
return nil, err
}
root.Score = int(summary.Score)
root.UserVote = int(summary.UserVote)
root.Answered = postTreeContainsRole(root, RoleAdmin)
return root, nil
}
func postTreeContainsRole(post *Post, role Role) bool {
for _, reply := range post.Replies {
if reply.AuthorRole == role || postTreeContainsRole(reply, role) {
return true
}
}
return false
}
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
}
// ListRootPostsByAuthor returns visible roots created by an author, newest first.
func ListRootPostsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPostsByAuthor(ctx, sqlc.ListRootPostsByAuthorParams{
AuthorID: authorID,
HiddenState: string(PostStateHidden),
RowLimit: ProfileListLimit,
})
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 posts, nil
}
// ListRootPostsAnsweredBy returns visible roots containing a reply by adminID.
func ListRootPostsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
HiddenState: string(PostStateHidden),
AdminID: adminID,
RowLimit: ProfileListLimit,
})
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 posts, nil
}
// SetRootPostState changes a root post's state.
func SetRootPostState(ctx context.Context, db *sql.DB, id string, state PostState) error {
switch state {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
n, err := sqlc.New(db).UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
ID: id,
})
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
return 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
}
+272
View File
@@ -0,0 +1,272 @@
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)
}
byAuthor, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID)
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != root.ID {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID)
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != root.ID {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
if err := mem.SetRootPostState(ctx, later.ID, PostStateHidden); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("reply state error = %v, want sql.ErrNoRows", err)
}
if err := mem.SetRootPostState(ctx, root.ID, PostStateHidden); err != nil {
t.Fatal(err)
}
if roots, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden author roots = %+v, %v", roots, err)
}
if roots, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden answered roots = %+v, %v", roots, 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
}
+24 -21
View File
@@ -109,41 +109,44 @@ func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
return u.SaveProfile(ctx)
}
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
q.db = p.db
return q.Create(ctx)
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
}
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
return GetQuestion(ctx, p.db, id, viewerID)
func (p *Postgres) GetPost(ctx context.Context, id string) (*Post, error) {
return GetPost(ctx, p.db, id)
}
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
return ListHunt(ctx, p.db, huntDate, viewerID)
func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, error) {
return GetPostThread(ctx, p.db, rootID)
}
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
return ListQuestionsByAuthor(ctx, p.db, authorID)
func (p *Postgres) GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error) {
return GetPostThreadForViewer(ctx, p.db, rootID, viewerID)
}
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Update(ctx)
}
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
q := &RankedQuestion{ID: id, db: p.db}
return q.Hide(ctx)
func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) {
return ListRootPosts(ctx, p.db, postDate, viewerID)
}
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
return GetAnswer(ctx, p.db, questionID)
func (p *Postgres) ListRootPostsByAuthor(ctx context.Context, authorID string) ([]Post, error) {
return ListRootPostsByAuthor(ctx, p.db, authorID)
}
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
a.db = p.db
return a.Upsert(ctx)
func (p *Postgres) ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error) {
return ListRootPostsAnsweredBy(ctx, p.db, adminID)
}
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
return Vote(ctx, p.db, userID, questionID, value)
func (p *Postgres) SetRootPostState(ctx context.Context, id string, state PostState) error {
return SetRootPostState(ctx, p.db, id, state)
}
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value)
}
-156
View File
@@ -1,156 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
"plumber/internal/store/sqlc"
)
// RankedQuestion is a question row with score / vote annotations for lists.
type RankedQuestion struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden bool
CreatedAt string
Score int
Answered bool
UserVote int
db *sql.DB
}
// NewQuestion returns a question bound to db (not yet inserted).
func NewQuestion(db *sql.DB) *RankedQuestion {
return &RankedQuestion{db: db}
}
// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty.
func (q *RankedQuestion) Create(ctx context.Context) error {
if q == nil || q.db == nil {
return fmt.Errorf("question: no database")
}
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{
ID: q.ID,
AuthorID: q.AuthorID,
Title: q.Title,
Body: q.Body,
City: q.City,
HuntDate: q.HuntDate,
CreatedAt: q.CreatedAt,
})
}
// Hide marks the question hidden.
func (q *RankedQuestion) Hide(ctx context.Context) error {
if q == nil || q.db == nil {
return fmt.Errorf("question: no database")
}
if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil {
return err
}
q.Hidden = true
return nil
}
func rankedFrom(
db *sql.DB,
id, authorID, authorName, title, body, city, huntDate, createdAt string,
hidden int32, score, answered, userVote int64,
) RankedQuestion {
return RankedQuestion{
ID: id,
AuthorID: authorID,
AuthorName: authorName,
Title: title,
Body: body,
City: city,
HuntDate: huntDate,
Hidden: hidden != 0,
CreatedAt: createdAt,
Score: int(score),
Answered: answered != 0,
UserVote: int(userVote),
db: db,
}
}
func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
ViewerID: viewerID,
HuntDate: huntDate,
RowLimit: HuntListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{
ViewerID: viewerID,
ID: id,
})
if err != nil {
return nil, err
}
q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)
return &q, nil
}
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{
AuthorID: authorID,
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{
AdminID: adminID,
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
-66
View File
@@ -1,66 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: answers.sql
package sqlc
import (
"context"
)
const getAnswer = `-- name: GetAnswer :one
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1
`
type GetAnswerRow struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) {
row := q.db.QueryRowContext(ctx, getAnswer, questionID)
var i GetAnswerRow
err := row.Scan(
&i.QuestionID,
&i.AuthorID,
&i.AuthorName,
&i.Body,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAnswer = `-- name: UpsertAnswer :exec
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at
`
type UpsertAnswerParams struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error {
_, err := q.db.ExecContext(ctx, upsertAnswer,
arg.QuestionID,
arg.AuthorID,
arg.Body,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
+12 -17
View File
@@ -5,26 +5,27 @@
package sqlc
import (
"database/sql"
"time"
)
type Answer struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
type Question struct {
type Post struct {
ID string
ParentID sql.NullString
AuthorID string
Title string
Body string
City string
HuntDate string
Hidden int32
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
type PostVote struct {
UserID string
PostID string
Value int32
}
type Session struct {
@@ -44,9 +45,3 @@ type User struct {
State string
CreatedAt string
}
type Vote struct {
UserID string
QuestionID string
Value int32
}
+592
View File
@@ -0,0 +1,592 @@
// 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 getRootPostVoteSummary = `-- name: GetRootPostVoteSummary :one
SELECT
COALESCE(SUM(value), 0)::bigint AS score,
COALESCE(
MAX(value) FILTER (WHERE user_id = $1),
0
)::bigint AS user_vote
FROM post_votes
WHERE post_id = $2
`
type GetRootPostVoteSummaryParams struct {
ViewerID string
RootID string
}
type GetRootPostVoteSummaryRow struct {
Score int64
UserVote int64
}
func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVoteSummaryParams) (GetRootPostVoteSummaryRow, error) {
row := q.db.QueryRowContext(ctx, getRootPostVoteSummary, arg.ViewerID, arg.RootID)
var i GetRootPostVoteSummaryRow
err := row.Scan(&i.Score, &i.UserVote)
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 listRootPostsAnsweredBy = `-- name: ListRootPostsAnsweredBy :many
WITH RECURSIVE ancestors AS (
SELECT p.id, p.parent_id
FROM posts p
WHERE p.author_id = $3
AND p.parent_id IS NOT NULL
UNION
SELECT parent.id, parent.parent_id
FROM posts parent
JOIN ancestors child ON child.parent_id = parent.id
)
SELECT DISTINCT
root.id, root.parent_id, root.author_id,
u.name AS author_name, u.role AS author_role,
root.title, root.body, root.city, root.post_date,
root.post_state, root.created_at, root.updated_at
FROM posts root
JOIN ancestors ON ancestors.id = root.id
JOIN users u ON u.id = root.author_id
WHERE root.parent_id IS NULL
AND root.post_state <> $1
ORDER BY root.created_at DESC, root.id DESC
LIMIT $2
`
type ListRootPostsAnsweredByParams struct {
HiddenState string
RowLimit int32
AdminID string
}
type ListRootPostsAnsweredByRow 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) ListRootPostsAnsweredBy(ctx context.Context, arg ListRootPostsAnsweredByParams) ([]ListRootPostsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPostsAnsweredBy, arg.HiddenState, arg.RowLimit, arg.AdminID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsAnsweredByRow{}
for rows.Next() {
var i ListRootPostsAnsweredByRow
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 listRootPostsByAuthor = `-- name: ListRootPostsByAuthor :many
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.parent_id IS NULL
AND p.author_id = $1
AND p.post_state <> $2
ORDER BY p.created_at DESC, p.id DESC
LIMIT $3
`
type ListRootPostsByAuthorParams struct {
AuthorID string
HiddenState string
RowLimit int32
}
type ListRootPostsByAuthorRow 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) ListRootPostsByAuthor(ctx context.Context, arg ListRootPostsByAuthorParams) ([]ListRootPostsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPostsByAuthor, arg.AuthorID, arg.HiddenState, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsByAuthorRow{}
for rows.Next() {
var i ListRootPostsByAuthorRow
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 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 updateRootPostState = `-- name: UpdateRootPostState :execrows
UPDATE posts
SET
post_state = $1,
updated_at = $2
WHERE id = $3
AND parent_id IS NULL
`
type UpdateRootPostStateParams struct {
PostState string
UpdatedAt string
ID string
}
func (q *Queries) UpdateRootPostState(ctx context.Context, arg UpdateRootPostStateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateRootPostState, arg.PostState, 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()
}
-316
View File
@@ -1,316 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: questions.sql
package sqlc
import (
"context"
)
const createQuestion = `-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`
type CreateQuestionParams struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
CreatedAt string
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
_, err := q.db.ExecContext(ctx, createQuestion,
arg.ID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.HuntDate,
arg.CreatedAt,
)
return err
}
const getQuestion = `-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = $2
`
type GetQuestionParams struct {
ViewerID string
ID string
}
type GetQuestionRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID)
var i GetQuestionRow
err := row.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
)
return i, err
}
const hideQuestion = `-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1
`
func (q *Queries) HideQuestion(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, hideQuestion, id)
return err
}
const listHunt = `-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = $2 AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC
LIMIT $3
`
type ListHuntParams struct {
ViewerID string
HuntDate string
RowLimit int32
}
type ListHuntRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListHuntRow{}
for rows.Next() {
var i ListHuntRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = $1 AND q.hidden = 0
ORDER BY ans.updated_at DESC
LIMIT $2
`
type ListQuestionsAnsweredByParams struct {
AdminID string
RowLimit int32
}
type ListQuestionsAnsweredByRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsAnsweredByRow{}
for rows.Next() {
var i ListQuestionsAnsweredByRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = $1 AND q.hidden = 0
ORDER BY q.created_at DESC
LIMIT $2
`
type ListQuestionsByAuthorParams struct {
AuthorID string
RowLimit int32
}
type ListQuestionsByAuthorRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsByAuthorRow{}
for rows.Next() {
var i ListQuestionsByAuthorRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-82
View File
@@ -1,82 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: votes.sql
package sqlc
import (
"context"
)
const deleteVote = `-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2
`
type DeleteVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
_, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
return err
}
const getVote = `-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2
`
type GetVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
var value int32
err := row.Scan(&value)
return value, err
}
const questionIsVisible = `-- name: QuestionIsVisible :one
SELECT EXISTS(
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
)::bool
`
func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRowContext(ctx, questionIsVisible, id)
var column_1 bool
err := row.Scan(&column_1)
return column_1, err
}
const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows
INSERT INTO votes (user_id, question_id, value)
SELECT $1, $2, $3
FROM questions q
WHERE q.id = $2 AND q.hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
WHERE EXISTS (
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
)
`
type UpsertVoteOnVisibleParams struct {
UserID string
QuestionID string
Value int32
}
func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+10 -12
View File
@@ -27,16 +27,14 @@ type Store interface {
SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error
CreateQuestion(ctx context.Context, q *RankedQuestion) error
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
HideQuestion(ctx context.Context, id string) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *Answer) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
Vote(ctx context.Context, userID, questionID string, value int) error
CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID string) (*Post, error)
GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error)
UpdatePost(ctx context.Context, post *Post) error
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error)
ListRootPostsByAuthor(ctx context.Context, authorID string) ([]Post, error)
ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) error
VotePost(ctx context.Context, userID, postID string, value int) error
}
+20 -2
View File
@@ -9,12 +9,19 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
// ErrLastAdmin is returned when demoting the only remaining admin.
var ErrLastAdmin = errors.New("cannot demote the last admin")
var (
// ErrLastAdmin is returned when demoting the only remaining admin.
ErrLastAdmin = errors.New("cannot demote the last admin")
// ErrDuplicateUsername is returned when inserting an existing username.
ErrDuplicateUsername = errors.New("username taken")
// ErrDuplicateEmail is returned when inserting or updating an existing email.
ErrDuplicateEmail = errors.New("email taken")
)
// Role is a user privilege level stored in users.role.
type Role string
@@ -51,6 +58,17 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
return ErrDuplicateEmail
}
return ErrDuplicateUsername
}
return err
}
func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
-71
View File
@@ -1,71 +0,0 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
// ErrDuplicateUsername is returned when inserting a username that already exists.
var ErrDuplicateUsername = errors.New("username taken")
// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
var ErrDuplicateEmail = errors.New("email taken")
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
var ErrHiddenOrMissing = errors.New("question not votable")
// SetVote sets the user's vote to value (1, -1, or 0 to clear) on a visible question.
func SetVote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
q := sqlc.New(db)
if value == 0 {
visible, err := q.QuestionIsVisible(ctx, questionID)
if err != nil {
return err
}
if !visible {
return ErrHiddenOrMissing
}
return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
UserID: userID,
QuestionID: questionID,
})
}
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
UserID: userID,
QuestionID: questionID,
Value: int32(value),
})
if err != nil {
return mapUniqueViolation(err)
}
if n == 0 {
return ErrHiddenOrMissing
}
return nil
}
// Vote is kept as an alias for SetVote for callers that still use the old name.
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
return SetVote(ctx, db, userID, questionID, value)
}
func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
return ErrDuplicateEmail
}
return ErrDuplicateUsername
}
return err
}
+276
View File
@@ -0,0 +1,276 @@
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"plumber/internal/mail"
"plumber/internal/store"
)
// handleCreatePost creates either a root question or a reply. Replies are
// limited to the root author and admins, and cannot be added to hidden threads.
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
user := currentUser(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
parentID := strings.TrimSpace(r.PostFormValue("parent_id"))
body := strings.TrimSpace(r.PostFormValue("body"))
if body == "" {
http.Error(w, "post body required", http.StatusBadRequest)
return
}
post := &store.Post{
AuthorID: user.ID,
Body: truncateRunes(body, 12000),
}
var parent, root *store.Post
if parentID == "" {
post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120)
post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80)
if post.Title == "" {
http.Error(w, "post title required", http.StatusBadRequest)
return
}
} else {
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "could not load thread", http.StatusInternalServerError)
return
}
if threadRoot.PostState == store.PostStateHidden {
http.NotFound(w, r)
return
}
if !canReplyToThread(user, threadRoot) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
parent = loadedParent
post.ParentID = &parent.ID
root = threadRoot
}
if err := s.store.CreatePost(r.Context(), post); err != nil {
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
}
http.Error(w, "could not save post", http.StatusInternalServerError)
return
}
if root == nil {
root = post
}
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
http.Redirect(
w,
r,
"/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID),
http.StatusSeeOther,
)
}
// notifyPostReply emails the root homeowner for admin replies and the direct
// parent author for homeowner replies.
func (s *Server) notifyPostReply(
parent *store.Post,
root *store.Post,
reply *store.Post,
replyAuthor *store.User,
) {
if parent == nil ||
root == nil ||
reply == nil ||
replyAuthor == nil ||
s.cfg.Mail == nil {
return
}
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if replyAuthor.Admin() {
recipientID = root.AuthorID
}
if recipientID == replyAuthor.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
// handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
user := currentUser(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
post, root, err := s.postAndRoot(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "could not load post", http.StatusInternalServerError)
return
}
if !canEditPost(user, post) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body := strings.TrimSpace(r.PostFormValue("body"))
if body == "" {
http.Error(w, "post body required", http.StatusBadRequest)
return
}
post.Body = truncateRunes(body, 12000)
if err := s.store.UpdatePost(r.Context(), post); err != nil {
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
}
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "could not save post", http.StatusInternalServerError)
return
}
http.Redirect(
w,
r,
"/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID),
http.StatusSeeOther,
)
}
// postAndRoot loads a post and follows its immutable parent chain to the root.
// It returns both so callers can authorize against the thread and redirect to it.
func (s *Server) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) {
postID = strings.TrimSpace(postID)
if postID == "" {
return nil, nil, sql.ErrNoRows
}
post, err := s.store.GetPost(ctx, postID)
if err != nil {
return nil, nil, err
}
current := post
seen := map[string]bool{}
for current.ParentID != nil {
if seen[current.ID] {
return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID)
}
seen[current.ID] = true
current, err = s.store.GetPost(ctx, *current.ParentID)
if err != nil {
return nil, nil, err
}
}
return post, current, nil
}
// canEditPost keeps homeowner posts owner-only while allowing admins to edit
// posts authored by an admin.
func canEditPost(user *store.User, post *store.Post) bool {
if user == nil || post == nil {
return false
}
if post.AuthorRole == store.RoleAdmin {
return user.Admin()
}
return user.ID == post.AuthorID
}
func canReplyToThread(user *store.User, root *store.Post) bool {
return user != nil &&
root != nil &&
root.PostState != store.PostStateHidden &&
(user.Admin() || user.ID == root.AuthorID)
}
func postLabel(post *store.Post) string {
if post == nil {
return ""
}
if post.ParentID == nil {
return "Question"
}
if post.AuthorRole == store.RoleAdmin {
return "Shop response"
}
return "Homeowner"
}
func postDepthClass(depth int) string {
switch depth {
case 0:
return "root"
case 1:
return "branch"
default:
return "deep"
}
}
func postPointers(posts []store.Post) []*store.Post {
out := make([]*store.Post, len(posts))
for i := range posts {
out[i] = &posts[i]
}
return out
}
+540
View File
@@ -0,0 +1,540 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestCreatePostRoutePermissions(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
otherCookies := loginUser(t, handler, other.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
otherCSRF := csrfForCookies(t, handler, otherCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
rec := postForm(handler, "/posts", url.Values{
"title": {"No CSRF"},
"body": {"Body"},
}, homeownerCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing CSRF status = %d, want 403", rec.Code)
}
anonRec := httptest.NewRecorder()
handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil))
anonCookies := anonRec.Result().Cookies()
anonCSRF := csrfFrom(anonRec.Body.String())
rec = postForm(handler, "/posts", url.Values{
"_csrf": {anonCSRF},
"title": {"Anonymous"},
"body": {"Body"},
}, anonCookies)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous create status = %d, want 401", rec.Code)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"It drips."},
"city": {"Oakland"},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 ||
roots[0].AuthorID != homeowner.ID ||
roots[0].Title != "Leaky sink" ||
roots[0].PostState != store.PostStateVisible {
t.Fatalf("created root = %+v", roots)
}
root := roots[0]
if got := rec.Header().Get("Location"); got != "/questions/"+root.ID+"#post-"+root.ID {
t.Fatalf("root redirect = %q", got)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {root.ID},
"body": {"The model is 123."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(thread.Replies) != 1 || thread.Replies[0].AuthorID != homeowner.ID {
t.Fatalf("homeowner reply missing: %+v", thread)
}
homeownerReply := thread.Replies[0]
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {homeownerReply.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin nested reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err = mem.GetPostThread(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(thread.Replies[0].Replies) != 1 ||
thread.Replies[0].Replies[0].AuthorID != admin.ID {
t.Fatalf("admin nested reply missing: %+v", thread)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {otherCSRF},
"parent_id": {homeownerReply.ID},
"body": {"I should not be here."},
}, otherCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("unrelated reply status = %d, want 403", rec.Code)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {"missing"},
"body": {"Missing parent"},
}, homeownerCookies)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing-parent reply status = %d, want 404", rec.Code)
}
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden thread",
Body: "Body",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {hidden.ID},
"body": {"Hidden reply"},
}, homeownerCookies)
if rec.Code != http.StatusNotFound {
t.Fatalf("hidden-thread reply status = %d, want 404", rec.Code)
}
}
func TestEditPostRoutePermissions(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
secondAdmin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
if err := mem.SetUserRole(context.Background(), secondAdmin.ID, store.RoleAdmin); err != nil {
t.Fatal(err)
}
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
otherCookies := loginUser(t, handler, other.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
secondAdminCookies := loginUser(t, handler, secondAdmin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
otherCSRF := csrfForCookies(t, handler, otherCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
secondAdminCSRF := csrfForCookies(t, handler, secondAdminCookies)
root := &store.Post{
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "Original body",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
rootID := root.ID
adminReply := &store.Post{
ParentID: &rootID,
AuthorID: admin.ID,
Body: "Original answer",
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
}
anonRec := httptest.NewRecorder()
handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil))
rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {csrfFrom(anonRec.Body.String())},
"body": {"Anonymous edit"},
}, anonRec.Result().Cookies())
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous edit status = %d, want 401", rec.Code)
}
rec = postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Updated homeowner body"},
"parent_id": {adminReply.ID},
"author_id": {other.ID},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner edit status = %d: %s", rec.Code, rec.Body.String())
}
saved, err := mem.GetPost(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if saved.Body != "Updated homeowner body" ||
saved.ParentID != nil ||
saved.AuthorID != homeowner.ID {
t.Fatalf("homeowner edit changed immutable fields: %+v", saved)
}
for name, session := range map[string]struct {
cookies []*http.Cookie
csrf string
}{
"other homeowner": {otherCookies, otherCSRF},
"admin": {adminCookies, adminCSRF},
} {
t.Run(name+" cannot edit homeowner post", func(t *testing.T) {
rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {session.csrf},
"body": {"Unauthorized edit"},
}, session.cookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Homeowner edit"},
}, homeownerCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("homeowner editing admin post status = %d, want 403", rec.Code)
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {secondAdminCSRF},
"body": {"Updated admin answer"},
}, secondAdminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin edit status = %d: %s", rec.Code, rec.Body.String())
}
saved, err = mem.GetPost(context.Background(), adminReply.ID)
if err != nil {
t.Fatal(err)
}
if saved.Body != "Updated admin answer" ||
saved.ParentID == nil ||
*saved.ParentID != root.ID ||
saved.AuthorID != admin.ID {
t.Fatalf("admin edit changed immutable fields: %+v", saved)
}
}
func TestPostReplyNotifications(t *testing.T) {
t.Parallel()
recording := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recording})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
rec := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"Water under the cabinet."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
}
if recording.Len() != 0 {
t.Fatalf("root create sent %d notifications", recording.Len())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("created roots = %+v, %v", roots, err)
}
root := roots[0]
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {root.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("admin reply thread = %+v, %v", thread, err)
}
adminReply := thread.Replies[0]
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {adminReply.ID},
"body": {"That fixed the drip."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err = mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
}
homeownerReply := thread.Replies[0].Replies[0]
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {adminReply.ID},
"body": {"One more plumber detail."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("nested admin reply status = %d: %s", rec.Code, rec.Body.String())
}
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != "One more plumber detail." ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {root.ID},
"body": {"A note to myself."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {adminCSRF},
"body": {"Replace the ceramic cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
}
noEmail := &store.User{
Username: uniq("no-email"),
PasswordHash: homeowner.PasswordHash,
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
t.Fatal(err)
}
noEmailRoot := &store.Post{
AuthorID: noEmail.ID,
Title: "Quiet thread",
Body: "No email configured.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
t.Fatal(err)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {noEmailRoot.ID},
"body": {"This should not send."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
}
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
}
}
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
root := &store.Post{
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
homeownerReply := &store.Post{
ParentID: &root.ID,
AuthorID: homeowner.ID,
Body: "The model number is 123.",
}
if err := mem.CreatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
adminReply := &store.Post{
ParentID: &homeownerReply.ID,
AuthorID: admin.ID,
Body: "Replace the cartridge.",
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
}
homeownerReply.Body = "The model number is 123A."
if err := mem.UpdatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range homeownerCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("question page status = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`id="post-` + root.ID + `"`,
`id="post-` + homeownerReply.ID + `"`,
`id="post-` + adminReply.ID + `"`,
`class="thread-post thread-post-branch`,
`class="thread-post thread-post-deep is-shop"`,
"Homeowner",
"Shop response",
"Edited",
`action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
`href="/questions/` + root.ID + `#post-` + homeownerReply.ID + `"`,
`href="/questions/` + root.ID + `#post-` + adminReply.ID + `"`,
`>The model number is 123A.</textarea>`,
`removeAttribute('open')`,
} {
if !strings.Contains(body, want) {
t.Fatalf("question page missing %q: %s", want, body)
}
}
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range adminCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `action="/posts/`+adminReply.ID+`/edit"`) ||
strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) {
t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String())
}
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("load CSRF form status = %d", rec.Code)
}
csrf := csrfFrom(rec.Body.String())
if csrf == "" {
t.Fatal("CSRF token missing")
}
return csrf
}
func postForm(
handler http.Handler,
path string,
values url.Values,
cookies []*http.Cookie,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
+7 -7
View File
@@ -23,7 +23,7 @@ import (
type profilePage struct {
page
States []struct{ Code, Name string }
Questions []store.RankedQuestion
Posts []store.Post
QuestionsLabel string
UploadsEnabled bool
Error string
@@ -255,16 +255,16 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var (
questions []store.RankedQuestion
label string
err error
posts []store.Post
label string
err error
)
if u.Admin() {
label = "Questions you answered"
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
posts, err = s.store.ListRootPostsAnsweredBy(r.Context(), u.ID)
} else {
label = "Your questions"
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
posts, err = s.store.ListRootPostsByAuthor(r.Context(), u.ID)
}
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
@@ -275,7 +275,7 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
s.exec(w, "profile", profilePage{
page: p,
States: geo.States,
Questions: questions,
Posts: posts,
QuestionsLabel: label,
UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg,
+62 -134
View File
@@ -3,7 +3,6 @@ package web
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
@@ -64,13 +63,12 @@ type huntPage struct {
Label string
IsToday bool
IsYesterday bool
Questions []store.RankedQuestion
Posts []*store.Post
}
type questionPage struct {
page
Question *store.RankedQuestion
Answer *store.Answer
Question *store.Post
}
type submitPage struct {
@@ -90,11 +88,19 @@ type authPage struct {
}
type voteCtx struct {
User *store.User
CSRF string
View string
Date string
Question store.RankedQuestion
User *store.User
CSRF string
View string
Date string
Post *store.Post
}
type threadPostCtx struct {
User *store.User
CSRF string
Root *store.Post
Post *store.Post
Depth int
}
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
@@ -105,12 +111,27 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
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}
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
},
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
"canReply": canReplyToThread,
"canEditPost": canEditPost,
"postLabel": postLabel,
"postDepth": postDepthClass,
"isEdited": func(post *store.Post) bool { return post != nil && post.UpdatedAt != post.CreatedAt },
"postTime": func(value string) string {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return value
}
return t.In(pacific.Loc).Format("Jan 2, 2006 · 3:04 PM")
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
"pacificLabel": pacific.Label,
"locationTag": func(u *store.User) string {
if u != nil {
@@ -175,8 +196,9 @@ func (s *Server) Handler() http.Handler {
r.Post("/submit", s.handleSubmit)
r.Get("/questions/{id}", s.handleQuestion)
r.Post("/questions/{id}/vote", s.handleVote)
r.Post("/questions/{id}/answer", s.handleAnswer)
r.Post("/questions/{id}/hide", s.handleHide)
r.Post("/posts", s.handleCreatePost)
r.Post("/posts/{id}/edit", s.handleEditPost)
r.Get("/login", s.handleLoginForm)
r.Post("/login", s.handleLogin)
r.Get("/register", s.handleRegisterForm)
@@ -279,7 +301,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
if u := currentUser(r); u != nil {
viewer = u.ID
}
questions, err := s.store.ListHunt(r.Context(), date, viewer)
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
return
@@ -295,7 +317,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
Label: label,
IsToday: pacific.IsToday(date),
IsYesterday: pacific.IsYesterday(date),
Questions: questions,
Posts: postPointers(posts),
})
}
@@ -339,17 +361,17 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
if len(city) > 80 {
city = truncateRunes(city, 80)
}
q := &store.RankedQuestion{
post := &store.Post{
AuthorID: u.ID,
Title: title,
Body: body,
City: city,
}
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
if err := s.store.CreatePost(r.Context(), post); err != nil {
http.Error(w, "could not save question", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther)
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
}
func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
@@ -358,29 +380,14 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
if u := currentUser(r); u != nil {
viewer = u.ID
}
q, err := s.store.GetQuestion(r.Context(), id, viewer)
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
post, err := s.store.GetPostThreadForViewer(r.Context(), id, viewer)
if err != nil || (post.PostState == store.PostStateHidden && !currentUser(r).Admin()) {
http.NotFound(w, r)
return
}
var ans *store.Answer
if q.Answered {
ans, err = s.store.GetAnswer(r.Context(), q.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Printf("question %s marked answered but answer missing", q.ID)
http.Error(w, "answer unavailable", http.StatusInternalServerError)
return
}
log.Printf("get answer %s: %v", q.ID, err)
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
}
s.exec(w, "question", questionPage{
page: s.basePage(r, q.Title),
Question: q,
Answer: ans,
page: s.basePage(r, post.Title),
Question: post,
})
}
@@ -410,8 +417,8 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid vote", http.StatusBadRequest)
return
}
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
if errors.Is(err, store.ErrHiddenOrMissing) {
if err := s.store.VotePost(r.Context(), u.ID, id, value); err != nil {
if errors.Is(err, store.ErrPostNotVotable) {
http.Error(w, "not found", http.StatusNotFound)
return
}
@@ -425,17 +432,17 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
s.renderLeaderboard(w, r, date)
return
}
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
post, err := s.store.GetPostThreadForViewer(r.Context(), id, u.ID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s.exec(w, "vote", voteCtx{
User: u,
CSRF: s.sessions.GetString(r.Context(), "csrf"),
View: "question",
Date: q.HuntDate,
Question: *q,
User: u,
CSRF: s.sessions.GetString(r.Context(), "csrf"),
View: "question",
Date: post.PostDate,
Post: post,
})
return
}
@@ -458,97 +465,18 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
if u := currentUser(r); u != nil {
viewer = u.ID
}
questions, err := s.store.ListHunt(r.Context(), date, viewer)
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
return
}
s.exec(w, "leaderboard", huntPage{
page: s.basePage(r, ""),
Date: date,
Questions: questions,
page: s.basePage(r, ""),
Date: date,
Posts: postPointers(posts),
})
}
func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
u := currentUser(r)
if !u.Admin() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
id := chi.URLParam(r, "id")
body := strings.TrimSpace(r.PostFormValue("body"))
if body == "" {
http.Error(w, "answer required", http.StatusBadRequest)
return
}
if len(body) > 12000 {
body = truncateRunes(body, 12000)
}
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,
Body: body,
}
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
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)
return
}
if isHTMX(r) {
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved})
return
}
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
@@ -559,17 +487,17 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
return
}
id := chi.URLParam(r, "id")
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
post, err := s.store.GetPost(r.Context(), id)
if err != nil || post.ParentID != nil {
http.NotFound(w, r)
return
}
if err := s.store.HideQuestion(r.Context(), id); err != nil {
if err := s.store.SetRootPostState(r.Context(), id, store.PostStateHidden); err != nil {
http.Error(w, "could not hide", http.StatusInternalServerError)
return
}
if isHTMX(r) && r.PostFormValue("view") == "list" {
s.renderLeaderboard(w, r, q.HuntDate)
s.renderLeaderboard(w, r, post.PostDate)
return
}
if isHTMX(r) {
+28 -185
View File
@@ -19,7 +19,6 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -169,6 +168,16 @@ func TestRegisterLoginAsk(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("submit form %d", rec.Code)
}
for _, want := range []string{
`src="/static/app.js"`,
`id="submit-progress"`,
`data-submit-once`,
`data-submit-button`,
} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("submit form missing %q: %s", want, rec.Body.String())
}
}
csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland")
req = httptest.NewRequest(http.MethodPost, "/submit", form)
@@ -417,21 +426,21 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, hubName, "hunter22")
q := &store.RankedQuestion{
root := &store.Post{
AuthorID: alice.ID,
Title: "Drip",
Body: "Under sink",
City: "Oakland",
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
ans := &store.Answer{
QuestionID: q.ID,
AuthorID: hub.ID,
Body: "Replace the cartridge.",
reply := &store.Post{
ParentID: &root.ID,
AuthorID: hub.ID,
Body: "Replace the cartridge.",
}
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
if err := mem.CreatePost(context.Background(), reply); err != nil {
t.Fatal(err)
}
@@ -483,24 +492,24 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
}
}
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
func TestMutationsVoteHideAndCSRF(t *testing.T) {
srv, mem := newTestServer(t, Config{})
h := srv.Handler()
adminName := uniq("admin")
userName := uniq("user")
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "hunter22")
q := &store.RankedQuestion{
q := &store.Post{
AuthorID: user.ID,
Title: "Pipe noise",
Body: "Clanking",
City: "SF",
HuntDate: pacific.Today(),
PostDate: pacific.Today(),
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
if err := mem.CreatePost(context.Background(), q); err != nil {
t.Fatal(err)
}
@@ -556,75 +565,16 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
}
got, err := mem.GetQuestion(context.Background(), q.ID, user.ID)
got, err := mem.GetPostThreadForViewer(context.Background(), q.ID, user.ID)
if err != nil || got.UserVote != 1 || got.Score != 1 {
t.Fatalf("vote not applied: %+v %v", got, err)
}
// Non-admin answer rejected
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", nil)
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Nope")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range userCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
}
// Admin answer success (HTMX)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range adminCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 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())
if rec.Code != http.StatusNotFound {
t.Fatalf("removed answer endpoint want 404, got %d", rec.Code)
}
// Hide invalid id
@@ -659,8 +609,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
}
hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
if err != nil || !hidden.Hidden {
hidden, err := mem.GetPost(context.Background(), q.ID)
if err != nil || hidden.PostState != store.PostStateHidden {
t.Fatalf("question not hidden: %+v %v", hidden, err)
}
}
@@ -713,113 +663,6 @@ func TestRegisterRequiresEmail(t *testing.T) {
}
}
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.
+31 -21
View File
@@ -14,34 +14,44 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> '';
CREATE TABLE IF NOT EXISTS questions (
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,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
hunt_date TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_questions_hunt_date ON questions(hunt_date, hidden);
CREATE TABLE IF NOT EXISTS votes (
user_id TEXT NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, question_id)
);
CREATE TABLE IF NOT EXISTS answers (
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
post_date TEXT NOT NULL DEFAULT '',
post_state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_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_author_created
ON posts(author_id, created_at DESC, id DESC);
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,
+144 -30
View File
@@ -41,6 +41,31 @@ body {
background-size: 100% 100%, 48px 48px;
}
.submit-progress {
position: fixed;
inset: 0 0 auto;
z-index: 100;
height: 3px;
overflow: hidden;
pointer-events: none;
}
.submit-progress[hidden] { display: none; }
.submit-progress-bar {
display: block;
width: 35%;
height: 100%;
background: var(--signal);
box-shadow: 0 0 12px var(--signal);
animation: submit-progress 900ms ease-in-out infinite;
}
@keyframes submit-progress {
from { transform: translateX(-100%); }
to { transform: translateX(300%); }
}
img, svg { display: block; }
a {
@@ -253,6 +278,12 @@ a {
.btn-primary:hover { filter: brightness(1.08); }
.btn:disabled {
cursor: wait;
opacity: 0.65;
filter: none;
}
.btn-ghost {
background: transparent;
color: var(--ink);
@@ -552,12 +583,14 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
padding: 18px 14px;
}
.post-content { min-width: 0; }
.question-page h1 {
font-size: clamp(1.5rem, 3.5vw, 2.1rem);
line-height: 1.15;
}
.q-body, .answer-body {
.post-body {
white-space: pre-wrap;
margin: 14px 0 0;
text-wrap: pretty;
@@ -574,18 +607,7 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
}
.crumb a:hover { color: var(--signal); }
.answer {
margin-top: 16px;
padding: 20px 18px;
background: var(--panel);
border: 1px solid var(--line);
}
.answer.is-in {
border-color: var(--signal);
}
.answer-kicker {
.post-kicker {
margin: 0 0 6px;
font-family: var(--mono);
text-transform: uppercase;
@@ -595,27 +617,99 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
color: var(--signal);
}
.answer h2 {
margin: 0;
font-size: 1.15rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
.conversation {
margin-top: 32px;
}
.byline {
.conversation-head {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.conversation h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
}
.conversation-head .eyebrow {
margin-bottom: 5px;
}
.thread {
display: grid;
gap: 12px;
}
.thread-post {
position: relative;
padding: 16px;
background: var(--panel);
border: 1px solid var(--line);
border-left: 2px solid var(--zinc);
overflow-wrap: anywhere;
}
.thread-post.is-shop {
border-left-color: var(--signal);
}
.thread-post:target,
.q-detail:target {
outline: 2px solid var(--signal);
outline-offset: 3px;
}
.thread-post-branch,
.thread-post-deep {
margin-left: clamp(12px, 4vw, 28px);
}
.thread-post-deep .thread-post-deep {
margin-left: 0;
}
.post-replies {
display: grid;
gap: 12px;
margin-top: 12px;
}
.post-meta {
margin: 6px 0 0;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
}
.answer-editor {
margin-top: 16px;
.edited {
display: inline-block;
margin-left: 8px;
color: var(--zinc);
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.post-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0 16px;
margin-top: 10px;
border-top: 1px solid var(--line);
}
.answer-editor summary {
.post-composer {
min-width: 0;
}
.post-composer[open] {
flex: 1 0 100%;
}
.post-composer summary {
display: flex;
width: fit-content;
min-height: 44px;
@@ -630,23 +724,39 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
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 {
.post-composer summary::-webkit-details-marker { display: none; }
.post-composer summary::marker { content: ""; }
.post-composer summary:hover,
.post-composer[open] summary { color: var(--signal); }
.post-composer summary:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.answer-editor .answer-form { margin-top: 4px; }
.post-form {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin: 0 0 14px;
}
.answer-form-actions {
.post-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.answer-form-actions .btn { flex: 1 1 10rem; }
.post-form-actions .btn { flex: 1 1 10rem; }
.post-permalink {
display: inline-flex;
align-items: center;
}
.post-hide {
margin: 0;
}
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
@@ -689,6 +799,10 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
@media (prefers-reduced-motion: reduce) {
.btn-primary:hover { filter: none; }
.submit-progress-bar {
width: 100%;
animation: none;
}
}
.admin-users { margin-top: 20px; overflow-x: auto; }
+58
View File
@@ -0,0 +1,58 @@
(() => {
const formSelector = "form[data-submit-once]";
function progressIndicator() {
return document.getElementById("submit-progress");
}
function resetForm(form) {
form.removeAttribute("aria-busy");
delete form.dataset.submitting;
const button = form.querySelector("[data-submit-button]");
if (!button) {
return;
}
button.disabled = false;
button.removeAttribute("aria-disabled");
if (button.dataset.idleLabel) {
button.textContent = button.dataset.idleLabel;
delete button.dataset.idleLabel;
}
}
document.addEventListener("submit", (event) => {
const form = event.target.closest(formSelector);
if (!form) {
return;
}
if (form.dataset.submitting === "true") {
event.preventDefault();
return;
}
form.dataset.submitting = "true";
form.setAttribute("aria-busy", "true");
const button = event.submitter || form.querySelector("[data-submit-button]");
if (button) {
button.dataset.idleLabel = button.textContent;
button.textContent = form.dataset.submittingLabel || "Posting…";
button.disabled = true;
button.setAttribute("aria-disabled", "true");
}
const progress = progressIndicator();
if (progress) {
progress.hidden = false;
}
});
window.addEventListener("pageshow", () => {
document.querySelectorAll(formSelector).forEach(resetForm);
const progress = progressIndicator();
if (progress) {
progress.hidden = true;
}
});
})();
+5
View File
@@ -11,8 +11,13 @@
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<div id="submit-progress" class="submit-progress" role="progressbar"
aria-label="Posting" aria-valuetext="Posting" hidden>
<span class="submit-progress-bar"></span>
</div>
<a class="skip" href="#main">Skip to content</a>
<header class="top">
<div class="top-inner">
-28
View File
@@ -1,28 +0,0 @@
{{define "answer"}}
<section id="answer-block" class="answer{{if .Answer}} is-in{{end}}">
{{if .Answer}}
<p class="answer-kicker">Shop response</p>
<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}}
</section>
{{end}}
+9 -9
View File
@@ -1,6 +1,6 @@
{{define "leaderboard"}}
<ol id="leaderboard" class="board" start="1">
{{if not .Questions}}
{{if not .Posts}}
<li class="empty">
{{if eq .Date .Today}}
<p class="empty-kicker">Queue empty</p>
@@ -10,20 +10,20 @@
{{end}}
</li>
{{else}}
{{range $i, $q := .Questions}}
{{range $i, $post := .Posts}}
<li class="row">
<span class="rank" aria-hidden="true">{{rank $i}}</span>
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $q)}}
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $post)}}
<div class="row-body">
<a class="q-title" href="/questions/{{$q.ID}}">{{$q.Title}}</a>
<a class="q-title" href="/questions/{{$post.ID}}">{{$post.Title}}</a>
<p class="meta">
<span>{{$q.AuthorName}}</span>
{{if $q.City}}<span class="dot" aria-hidden="true">·</span><span>{{$q.City}}</span>{{end}}
{{if $q.Answered}}<span class="badge">Answered</span>{{end}}
<span>{{$post.AuthorName}}</span>
{{if $post.City}}<span class="dot" aria-hidden="true">·</span><span>{{$post.City}}</span>{{end}}
{{if $post.Answered}}<span class="badge">Answered</span>{{end}}
</p>
{{if isAdmin $.User}}
<form class="inline-hide" method="post" action="/questions/{{$q.ID}}/hide"
hx-post="/questions/{{$q.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
<form class="inline-hide" method="post" action="/questions/{{$post.ID}}/hide"
hx-post="/questions/{{$post.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<input type="hidden" name="view" value="list">
<button type="submit" class="linkish">Hide</button>
+71
View File
@@ -0,0 +1,71 @@
{{define "postActions"}}
<div class="post-actions">
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="parent_id" value="{{.Post.ID}}">
<label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label>
<textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea>
<div class="post-form-actions">
<button type="submit" class="btn btn-primary" data-submit-button>Post reply</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
{{if canEditPost .User .Post}}
<details class="post-composer">
<summary>Edit</summary>
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="edit-{{.Post.ID}}">Edit post</label>
<textarea id="edit-{{.Post.ID}}" name="body" rows="5" required
maxlength="12000">{{.Post.Body}}</textarea>
<div class="post-form-actions">
<button type="submit" class="btn btn-primary">Save changes</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
<a class="linkish post-permalink"
href="/questions/{{.Root.ID}}#post-{{.Post.ID}}"
aria-label="Permanent link to post by {{.Post.AuthorName}}">Permalink</a>
{{if and (not .Post.ParentID) (isAdmin .User)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="linkish">Hide</button>
</form>
{{end}}
</div>
{{end}}
{{define "threadReply"}}
{{$ctx := .}}
<article id="post-{{.Post.ID}}"
class="thread-post thread-post-{{postDepth .Depth}}{{if eq .Post.AuthorRole "admin"}} is-shop{{end}}">
<header class="post-head">
<p class="post-kicker">{{postLabel .Post}}</p>
<p class="post-meta">
<span>{{.Post.AuthorName}}</span>
<span class="dot" aria-hidden="true">·</span>
<time datetime="{{.Post.CreatedAt}}">{{postTime .Post.CreatedAt}}</time>
{{if isEdited .Post}}<span class="edited">Edited</span>{{end}}
</p>
</header>
<p class="post-body">{{.Post.Body}}</p>
{{template "postActions" .}}
{{if .Post.Replies}}
<div class="post-replies">
{{range .Post.Replies}}
{{template "threadReply" (postCtx $ctx.User $ctx.CSRF $ctx.Root . (add $ctx.Depth 1))}}
{{end}}
</div>
{{end}}
</article>
{{end}}
+13 -13
View File
@@ -1,26 +1,26 @@
{{define "vote"}}
<div id="vote-{{.Question.ID}}" class="vote">
<div id="vote-{{.Post.ID}}" class="vote">
{{if .User}}
<form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<form method="post" action="/questions/{{.Post.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if eq .Question.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
{{if eq .Post.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
<input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
<button type="submit" class="vote-btn{{if eq .Post.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Post.UserVote 1}}true{{else}}false{{end}}">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</button>
</form>
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
<form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<span class="score" aria-label="Net score {{.Post.Score}}">{{.Post.Score}}</span>
<form method="post" action="/questions/{{.Post.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if eq .Question.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
{{if eq .Post.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
<input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">
<button type="submit" class="vote-btn{{if eq .Post.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Post.UserVote -1}}true{{else}}false{{end}}">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</button>
</form>
@@ -28,7 +28,7 @@
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to upvote">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</a>
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
<span class="score" aria-label="Net score {{.Post.Score}}">{{.Post.Score}}</span>
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to downvote">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</a>
+3 -3
View File
@@ -43,12 +43,12 @@
<section class="profile-questions" aria-labelledby="profile-q-heading">
<h2 id="profile-q-heading">{{.QuestionsLabel}}</h2>
{{if .Questions}}
{{if .Posts}}
<ul class="profile-q-list">
{{range .Questions}}
{{range .Posts}}
<li>
<a href="/questions/{{.ID}}">{{.Title}}</a>
<span class="meta">{{.HuntDate}}</span>
<span class="meta">{{.PostDate}}</span>
</li>
{{end}}
</ul>
+27 -23
View File
@@ -1,37 +1,41 @@
{{define "question"}}
{{template "header" .}}
<main id="main" class="wrap question-page">
<p class="crumb"><a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">← {{pacificLabel .Question.HuntDate}}</a></p>
<article class="q-detail">
{{template "vote" (voteCtx .User .CSRF "question" .Question.HuntDate .Question)}}
<div>
<p class="crumb"><a href="{{if eq .Question.PostDate .Today}}/{{else}}/hunt/{{.Question.PostDate}}{{end}}">← {{pacificLabel .Question.PostDate}}</a></p>
<article id="post-{{.Question.ID}}" class="q-detail">
{{template "vote" (voteCtx .User .CSRF "question" .Question.PostDate .Question)}}
<div class="post-content">
<p class="post-kicker">Question</p>
<h1>{{.Question.Title}}</h1>
<p class="meta">
<span>{{.Question.AuthorName}}</span>
{{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}}
<span class="dot" aria-hidden="true">·</span>
<a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">{{.Question.HuntDate}}</a>
<a href="{{if eq .Question.PostDate .Today}}/{{else}}/hunt/{{.Question.PostDate}}{{end}}">{{.Question.PostDate}}</a>
{{if eq .Question.PostState "locked"}}<span class="badge">Locked</span>{{end}}
{{if eq .Question.PostState "hidden"}}<span class="badge">Hidden</span>{{end}}
{{if isEdited .Question}}<span class="edited">Edited</span>{{end}}
</p>
<p class="q-body">{{.Question.Body}}</p>
{{if isAdmin .User}}
<form method="post" action="/questions/{{.Question.ID}}/hide"
hx-post="/questions/{{.Question.ID}}/hide" hx-target="body">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="linkish">Hide this question</button>
</form>
{{end}}
<p class="post-body">{{.Question.Body}}</p>
{{template "postActions" (postCtx .User .CSRF .Question .Question 0)}}
</div>
</article>
{{template "answer" .}}
{{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">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}}
<section class="conversation" aria-labelledby="conversation-heading">
<div class="conversation-head">
<p class="eyebrow">Thread</p>
<h2 id="conversation-heading">Conversation</h2>
</div>
{{if .Question.Replies}}
<div class="thread">
{{$page := .}}
{{range .Question.Replies}}
{{template "threadReply" (postCtx $page.User $page.CSRF $page.Question . 1)}}
{{end}}
</div>
{{else}}
<p class="waiting">No replies yet.</p>
{{end}}
</section>
</main>
{{template "footer" .}}
{{end}}
+3 -2
View File
@@ -4,7 +4,8 @@
<h1>Ask a question</h1>
<p class="lede">It lands on todays hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
<form class="ask" method="post" action="/submit">
<form class="ask" method="post" action="/submit"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="title">Title</label>
<input id="title" name="title" type="text" required maxlength="120" value="{{.TitleVal}}" placeholder="Water heater popping after showers">
@@ -12,7 +13,7 @@
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
<label for="city">City <span class="optional">(optional)</span></label>
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
<button type="submit" class="btn btn-primary">Submit to todays hunt</button>
<button type="submit" class="btn btn-primary" data-submit-button>Submit to todays hunt</button>
</form>
</main>
{{template "footer" .}}