Author SHA1 Message Date
codegirl007 06fcc16c02 Add Discord outbound posting.
CI / test (pull_request) Successful in 6m20s
2026-08-29 01:13:44 -07:00
codegirl007 b8f1d88d6e Add an in-process post event bus.
CI / test (pull_request) Successful in 6m17s
2026-08-29 01:06:26 -07:00
codegirl007 19fea892f3 Add post image storage (#10)
CI / test (push) Successful in 6m15s
Adds migration 010, ordered attachment metadata, and transaction-safe image persistence for posts and replies.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 08:00:37 +00:00
codegirl007 c6f80e243d Merge pull request 'App' (#9) from app into master
CI / test (push) Successful in 6m17s
Reviewed-on: #9
2026-08-27 16:25:35 +00:00
codegirl007 8a2c7de8ca Add post permalinks (#8)
CI / test (pull_request) Successful in 6m17s
Reviewed-on: #8
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:21:38 +00:00
codegirl007 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

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

Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:03:33 +00:00
codegirl007 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 f33a0739c2 Initial Ask a Plumber First server 2026-08-22 12:26:32 -07:00
52 changed files with 2709 additions and 1452 deletions
+5 -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=
@@ -26,3 +26,7 @@ SECURE_COOKIE=0
# SPACES_BUCKET=your-bucket
# SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
# SPACES_CDN_BASE=https://your-bucket.nyc3.cdn.digitaloceanspaces.com
# Discord bot subscriber. Leave unset to disable.
# DISCORD_BOT_TOKEN=
# DISCORD_CHANNEL_ID=
# DISCORD_ADMIN_MAP=123456789012345678:plumber,234567890123456789:otheradmin
+14 -2
View File
@@ -17,6 +17,8 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/discord"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
"plumber/internal/web"
@@ -34,7 +36,15 @@ func main() {
if err != nil {
log.Fatalf("mail: %v", err)
}
handler := newHandler(db, sessions, uploader, notifier)
bus := events.New()
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus)
if err != nil {
log.Fatalf("discord: %v", err)
}
if bot != nil {
defer bot.Close()
}
handler := newHandler(db, sessions, uploader, notifier, bus)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -58,13 +68,15 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier, bus events.Publisher) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
Mail: notifier,
Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
})
if err != nil {
log.Fatalf("server: %v", err)
-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;
+32
View File
@@ -0,0 +1,32 @@
-- name: GetDiscordPostLinkByPostID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE post_id = sqlc.arg(post_id);
-- name: GetDiscordPostLinkByMessageID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_message_id = sqlc.arg(discord_message_id);
-- name: GetDiscordPostLinkByThreadID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_thread_id = sqlc.arg(discord_thread_id)
AND discord_thread_id <> '';
-- name: UpsertDiscordPostLink :exec
INSERT INTO discord_post_links (
post_id, discord_message_id, discord_thread_id, created_at
)
VALUES (
sqlc.arg(post_id),
sqlc.arg(discord_message_id),
sqlc.arg(discord_thread_id),
sqlc.arg(created_at)
)
ON CONFLICT (post_id) DO UPDATE SET
discord_message_id = EXCLUDED.discord_message_id,
discord_thread_id = CASE
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
ELSE discord_post_links.discord_thread_id
END;
+46
View File
@@ -61,6 +61,52 @@ SET
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
sqlc.arg(id),
sqlc.arg(post_id),
sqlc.arg(object_key),
sqlc.arg(public_url),
sqlc.arg(description),
sqlc.arg(position),
sqlc.arg(width),
sqlc.arg(height),
sqlc.arg(created_at)
);
-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = sqlc.arg(post_id)
ORDER BY position;
-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position;
-- name: UpdateRootPostState :execrows
UPDATE posts
SET
-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
);
+4 -1
View File
@@ -7,10 +7,12 @@ require (
github.com/aws/aws-sdk-go-v2 v1.43.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.37
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
github.com/bwmarrin/discordgo v0.29.0
github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/resend/resend-go/v3 v3.16.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
)
@@ -25,10 +27,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
github.com/aws/smithy-go v1.27.8 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/resend/resend-go/v3 v3.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+12
View File
@@ -24,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA=
github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,6 +33,8 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -50,14 +54,22 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+102
View File
@@ -0,0 +1,102 @@
package discord
import (
"context"
"github.com/bwmarrin/discordgo"
)
// API is the Discord REST surface used by the outbound subscriber.
type API interface {
SendToChannel(ctx context.Context, channelID string, msg Message) (messageID string, err error)
StartThread(ctx context.Context, channelID, messageID, name string) (threadID string, err error)
SendToThread(ctx context.Context, threadID string, msg Message) (messageID string, err error)
Edit(ctx context.Context, channelID, messageID string, msg Message) error
Close() error
}
type sessionAPI struct {
session *discordgo.Session
}
func (s *sessionAPI) SendToChannel(_ context.Context, channelID string, msg Message) (string, error) {
sent, err := s.session.ChannelMessageSendComplex(channelID, toMessageSend(msg))
if err != nil {
return "", err
}
return sent.ID, nil
}
func (s *sessionAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
thread, err := s.session.MessageThreadStartComplex(channelID, messageID, &discordgo.ThreadStart{
Name: name,
AutoArchiveDuration: 10080,
})
if err != nil {
return "", err
}
return thread.ID, nil
}
func (s *sessionAPI) SendToThread(ctx context.Context, threadID string, msg Message) (string, error) {
return s.SendToChannel(ctx, threadID, msg)
}
func (s *sessionAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
embeds := toEmbeds(msg)
_, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
ID: messageID,
Channel: channelID,
Embeds: &embeds,
})
return err
}
func (s *sessionAPI) Close() error {
if s == nil || s.session == nil {
return nil
}
return s.session.Close()
}
func toMessageSend(msg Message) *discordgo.MessageSend {
return &discordgo.MessageSend{
Embeds: toEmbeds(msg),
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
}
func toEmbeds(msg Message) []*discordgo.MessageEmbed {
main := &discordgo.MessageEmbed{
Title: msg.Title,
URL: msg.URL,
Description: msg.Description,
Color: embedColor,
}
if msg.City != "" {
main.Fields = append(main.Fields, &discordgo.MessageEmbedField{
Name: "City",
Value: msg.City,
Inline: true,
})
}
if msg.Author != "" {
main.Fields = append(main.Fields, &discordgo.MessageEmbedField{
Name: "Author",
Value: msg.Author,
Inline: true,
})
}
embeds := []*discordgo.MessageEmbed{main}
for i, url := range msg.ImageURLs {
if i == 0 {
main.Image = &discordgo.MessageEmbedImage{URL: url}
continue
}
embeds = append(embeds, &discordgo.MessageEmbed{
Color: embedColor,
Image: &discordgo.MessageEmbedImage{URL: url},
})
}
return embeds
}
+178
View File
@@ -0,0 +1,178 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/store"
)
const discordTimeout = 15 * time.Second
// Bot posts site events to a Discord channel and owns post-to-message links.
type Bot struct {
channelID string
links store.DiscordLinkStore
api API
}
// New constructs an outbound subscriber. Tests inject a fake API.
func New(channelID string, links store.DiscordLinkStore, api API) *Bot {
return &Bot{channelID: strings.TrimSpace(channelID), links: links, api: api}
}
// FromEnv builds a bot when Discord env is set. Missing config is a no-op.
func FromEnv(links store.DiscordLinkStore, bus *events.Bus) (*Bot, error) {
token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN"))
channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID"))
if token == "" && channelID == "" {
return nil, nil
}
if token == "" {
return nil, fmt.Errorf("DISCORD_BOT_TOKEN is required when DISCORD_CHANNEL_ID is set")
}
if channelID == "" {
return nil, fmt.Errorf("DISCORD_CHANNEL_ID is required when DISCORD_BOT_TOKEN is set")
}
if links == nil {
return nil, fmt.Errorf("discord links store is required")
}
session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, err
}
bot := New(channelID, links, &sessionAPI{session: session})
if bus != nil {
bus.Subscribe(bot.Handle)
}
log.Printf("discord: outbound subscriber enabled")
return bot, nil
}
// Close releases the Discord session.
func (b *Bot) Close() error {
if b == nil || b.api == nil {
return nil
}
return b.api.Close()
}
// Handle processes one site event. Failures are logged and do not fail the request.
func (b *Bot) Handle(_ context.Context, ev any) {
if b == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discordTimeout)
defer cancel()
switch e := ev.(type) {
case events.PostCreated:
b.onCreated(ctx, e.PostEvent)
case events.PostUpdated:
b.onUpdated(ctx, e.PostEvent)
}
}
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
if isRoot(ev) {
b.createRoot(ctx, ev)
return
}
b.createReply(ctx, ev)
}
func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
link, err := b.links.GetByPostID(ctx, ev.PostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
b.onCreated(ctx, ev)
return
}
log.Printf("discord: load link %s: %v", ev.PostID, err)
return
}
channelID, err := b.editChannel(ctx, ev, link)
if err != nil {
log.Printf("discord: edit channel %s: %v", ev.PostID, err)
return
}
if err := b.api.Edit(ctx, channelID, link.MessageID, formatMessage(ev)); err != nil {
log.Printf("discord: edit %s: %v", ev.PostID, err)
return
}
log.Printf("discord: edited %s", ev.PostID)
}
func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatMessage(ev)
messageID, err := b.api.SendToChannel(ctx, b.channelID, msg)
if err != nil {
log.Printf("discord: send root %s: %v", ev.PostID, err)
return
}
threadID, err := b.api.StartThread(ctx, b.channelID, messageID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
MessageID: messageID,
ThreadID: threadID,
}); err != nil {
log.Printf("discord: save root link %s: %v", ev.PostID, err)
return
}
log.Printf("discord: posted root %s", ev.PostID)
}
func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return
}
log.Printf("discord: load root link %s: %v", ev.RootID, err)
return
}
if strings.TrimSpace(root.ThreadID) == "" {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return
}
messageID, err := b.api.SendToThread(ctx, root.ThreadID, formatMessage(ev))
if err != nil {
log.Printf("discord: send reply %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
MessageID: messageID,
}); err != nil {
log.Printf("discord: save reply link %s: %v", ev.PostID, err)
return
}
log.Printf("discord: posted reply %s", ev.PostID)
}
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {
if strings.TrimSpace(link.ThreadID) != "" {
return b.channelID, nil
}
root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil {
return "", err
}
if strings.TrimSpace(root.ThreadID) == "" {
return "", fmt.Errorf("root %s has no thread", ev.RootID)
}
return root.ThreadID, nil
}
+247
View File
@@ -0,0 +1,247 @@
package discord
import (
"context"
"sync"
"testing"
"strconv"
"plumber/internal/events"
"plumber/internal/store"
)
type recordedSend struct {
Kind string
ChannelID string
Name string
Msg Message
}
type fakeAPI struct {
mu sync.Mutex
sends []recordedSend
edits []recordedSend
next int
failSend error
}
func (f *fakeAPI) SendToChannel(_ context.Context, channelID string, msg Message) (string, error) {
return f.record("channel", channelID, "", msg)
}
func (f *fakeAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.next++
f.sends = append(f.sends, recordedSend{
Kind: "thread",
ChannelID: channelID,
Name: name,
Msg: Message{ThreadName: name, URL: messageID},
})
return "thread-" + messageID, nil
}
func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) {
return f.record("thread-msg", threadID, "", msg)
}
func (f *fakeAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
f.mu.Lock()
defer f.mu.Unlock()
f.edits = append(f.edits, recordedSend{
Kind: "edit",
ChannelID: channelID,
Name: messageID,
Msg: msg,
})
return nil
}
func (f *fakeAPI) Close() error { return nil }
func (f *fakeAPI) record(kind, channelID, name string, msg Message) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSend != nil {
return "", f.failSend
}
f.next++
id := "msg-" + strconv.Itoa(f.next)
f.sends = append(f.sends, recordedSend{Kind: kind, ChannelID: channelID, Name: name, Msg: msg})
return id, nil
}
func TestOutboundRootReplyAndEdit(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
ctx := context.Background()
root := events.PostEvent{
PostID: "root-1",
RootID: "root-1",
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
AuthorName: "sam",
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-root-1",
Images: []events.Image{{URL: "https://cdn.example/a.jpg"}, {URL: "https://cdn.example/b.jpg"}},
}
bot.Handle(ctx, events.PostCreated{PostEvent: root})
if len(api.sends) != 2 || api.sends[0].Kind != "channel" || api.sends[1].Kind != "thread" {
t.Fatalf("root sends = %+v", api.sends)
}
if api.sends[0].ChannelID != "channel-1" || api.sends[1].Name != "Leaky sink" {
t.Fatalf("root routing = %+v", api.sends)
}
if got := api.sends[0].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" {
t.Fatalf("root images = %v", got)
}
link, err := links.GetByPostID(ctx, "root-1")
if err != nil || link.MessageID != "msg-1" || link.ThreadID != "thread-msg-1" {
t.Fatalf("root link = %+v, %v", link, err)
}
reply := events.PostEvent{
PostID: "reply-1",
RootID: "root-1",
ParentID: "root-1",
Body: "Replace the cartridge.",
AuthorName: "plumber",
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1",
}
bot.Handle(ctx, events.PostCreated{PostEvent: reply})
if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-msg-1" {
t.Fatalf("reply sends = %+v", api.sends)
}
replyLink, err := links.GetByPostID(ctx, "reply-1")
if err != nil || replyLink.MessageID != "msg-3" || replyLink.ThreadID != "" {
t.Fatalf("reply link = %+v, %v", replyLink, err)
}
root.Body = "Updated leak."
bot.Handle(ctx, events.PostUpdated{PostEvent: root})
if len(api.edits) != 1 || api.edits[0].ChannelID != "channel-1" || api.edits[0].Name != "msg-1" {
t.Fatalf("root edit = %+v", api.edits)
}
if api.edits[0].Msg.Description != "Updated leak." {
t.Fatalf("root edit body = %+v", api.edits[0].Msg)
}
reply.Body = "Use a ceramic cartridge."
bot.Handle(ctx, events.PostUpdated{PostEvent: reply})
if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-msg-1" || api.edits[1].Name != "msg-3" {
t.Fatalf("reply edit = %+v", api.edits)
}
}
func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) {
t.Parallel()
api := &fakeAPI{}
bot := New("channel-1", newMemoryLinks(), api)
bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: "reply-1",
RootID: "missing",
ParentID: "missing",
Body: "Orphan reply",
}})
if len(api.sends) != 0 {
t.Fatalf("unexpected sends %+v", api.sends)
}
}
func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
bot.Handle(context.Background(), events.PostUpdated{PostEvent: events.PostEvent{
PostID: "root-2",
RootID: "root-2",
Title: "Late question",
Body: "Created while Discord was down.",
}})
link, err := links.GetByPostID(context.Background(), "root-2")
if err != nil || link.ThreadID == "" || len(api.sends) != 2 {
t.Fatalf("late create link=%+v sends=%+v err=%v", link, api.sends, err)
}
}
func TestFormatMessage(t *testing.T) {
t.Parallel()
got := formatMessage(events.PostEvent{
Title: "Leaky sink",
Body: "It drips.",
City: "Oakland",
AuthorName: "sam",
Permalink: "https://example.com/q",
Images: []events.Image{{URL: "https://cdn.example/a.jpg", Description: "ignored"}},
})
if got.Title != "Leaky sink" ||
got.Description != "It drips." ||
got.City != "Oakland" ||
got.Author != "sam" ||
got.URL != "https://example.com/q" ||
got.ThreadName != "Leaky sink" ||
len(got.ImageURLs) != 1 {
t.Fatalf("format = %+v", got)
}
reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""})
if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Question" {
t.Fatalf("reply format = %+v", reply)
}
}
func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil)
if err != nil || bot != nil {
t.Fatalf("disabled FromEnv = (%v, %v)", bot, err)
}
}
func TestFromEnvRequiresBoth(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "token")
t.Setenv("DISCORD_CHANNEL_ID", "")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil {
t.Fatal("expected error when channel is missing")
}
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "channel")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil {
t.Fatal("expected error when token is missing")
}
}
func TestMemoryLinkUpsertKeepsThread(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
ctx := context.Background()
if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m1", ThreadID: "t1"}); err != nil {
t.Fatal(err)
}
if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m2"}); err != nil {
t.Fatal(err)
}
got, err := links.GetByPostID(ctx, "p")
if err != nil || got.MessageID != "m2" || got.ThreadID != "t1" {
t.Fatalf("upsert keep thread = %+v, %v", got, err)
}
if _, err := links.GetByMessageID(ctx, "m2"); err != nil {
t.Fatal(err)
}
if _, err := links.GetRootByThreadID(ctx, "t1"); err != nil {
t.Fatal(err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package discord
import (
"strings"
"plumber/internal/events"
)
const (
embedTitleLimit = 256
embedDescriptionLimit = 4096
threadNameLimit = 100
embedColor = 0xe96a26
)
// Message is a Discord-ready snapshot of a site post event.
type Message struct {
Title string
URL string
Description string
City string
Author string
ImageURLs []string
ThreadName string
}
func formatMessage(ev events.PostEvent) Message {
title := strings.TrimSpace(ev.Title)
if title == "" {
title = "Reply"
}
author := strings.TrimSpace(ev.AuthorName)
if author == "" {
author = "Someone"
}
msg := Message{
Title: truncateRunes(title, embedTitleLimit),
URL: strings.TrimSpace(ev.Permalink),
Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
City: strings.TrimSpace(ev.City),
Author: author,
ThreadName: threadName(ev.Title),
}
for _, img := range ev.Images {
url := strings.TrimSpace(img.URL)
if url == "" {
continue
}
msg.ImageURLs = append(msg.ImageURLs, url)
}
return msg
}
func threadName(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "Question"
}
return truncateRunes(title, threadNameLimit)
}
func truncateRunes(s string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
func isRoot(ev events.PostEvent) bool {
return strings.TrimSpace(ev.ParentID) == ""
}
+88
View File
@@ -0,0 +1,88 @@
package discord
import (
"context"
"database/sql"
"strings"
"sync"
"time"
"plumber/internal/store"
)
// memoryLinks is an in-process DiscordLinkStore for tests.
type memoryLinks struct {
mu sync.Mutex
byPost map[string]store.DiscordLink
byMessage map[string]string
byThread map[string]string
}
func newMemoryLinks() *memoryLinks {
return &memoryLinks{
byPost: map[string]store.DiscordLink{},
byMessage: map[string]string{},
byThread: map[string]string{},
}
}
func (m *memoryLinks) GetByPostID(_ context.Context, postID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
link, ok := m.byPost[strings.TrimSpace(postID)]
if !ok {
return nil, sql.ErrNoRows
}
cp := link
return &cp, nil
}
func (m *memoryLinks) GetByMessageID(_ context.Context, messageID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
postID, ok := m.byMessage[strings.TrimSpace(messageID)]
if !ok {
return nil, sql.ErrNoRows
}
link := m.byPost[postID]
cp := link
return &cp, nil
}
func (m *memoryLinks) GetRootByThreadID(_ context.Context, threadID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
postID, ok := m.byThread[strings.TrimSpace(threadID)]
if !ok {
return nil, sql.ErrNoRows
}
link := m.byPost[postID]
cp := link
return &cp, nil
}
func (m *memoryLinks) Upsert(_ context.Context, link store.DiscordLink) error {
m.mu.Lock()
defer m.mu.Unlock()
link.PostID = strings.TrimSpace(link.PostID)
link.MessageID = strings.TrimSpace(link.MessageID)
link.ThreadID = strings.TrimSpace(link.ThreadID)
if link.CreatedAt == "" {
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
if prev, ok := m.byPost[link.PostID]; ok {
delete(m.byMessage, prev.MessageID)
if prev.ThreadID != "" {
delete(m.byThread, prev.ThreadID)
}
if link.ThreadID == "" {
link.ThreadID = prev.ThreadID
}
}
m.byPost[link.PostID] = link
m.byMessage[link.MessageID] = link.PostID
if link.ThreadID != "" {
m.byThread[link.ThreadID] = link.PostID
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package events
import (
"context"
"log"
"sync"
)
const defaultBuffer = 64
// Publisher is the site-facing write side of the bus.
type Publisher interface {
Publish(ctx context.Context, ev any)
}
// Bus is an in-process pub/sub with one worker and a bounded queue.
type Bus struct {
ch chan any
mu sync.Mutex
subs []func(context.Context, any)
closed sync.Once
}
// New starts a worker that delivers events to subscribers in publish order.
func New() *Bus {
return newBus(defaultBuffer, true)
}
func newBus(buffer int, start bool) *Bus {
if buffer < 1 {
buffer = 1
}
b := &Bus{ch: make(chan any, buffer)}
if start {
go b.loop()
}
return b
}
// Publish enqueues ev. It never blocks the caller; a full buffer is dropped.
func (b *Bus) Publish(_ context.Context, ev any) {
if b == nil {
return
}
select {
case b.ch <- ev:
default:
log.Printf("events: dropped %T", ev)
}
}
// Subscribe adds a handler. Handlers run serially on the worker.
func (b *Bus) Subscribe(fn func(context.Context, any)) {
if b == nil || fn == nil {
return
}
b.mu.Lock()
b.subs = append(b.subs, fn)
b.mu.Unlock()
}
func (b *Bus) loop() {
for ev := range b.ch {
b.mu.Lock()
subs := append([]func(context.Context, any){}, b.subs...)
b.mu.Unlock()
for _, fn := range subs {
fn(context.Background(), ev)
}
}
}
// Close stops the worker. Safe to call more than once.
func (b *Bus) Close() {
if b == nil {
return
}
b.closed.Do(func() { close(b.ch) })
}
+104
View File
@@ -0,0 +1,104 @@
package events
import (
"context"
"sync"
"testing"
"time"
)
func TestPermalink(t *testing.T) {
t.Parallel()
tests := []struct {
base, root, post, want string
}{
{"", "root-1", "post-2", "/questions/root-1#post-post-2"},
{"https://www.askaplumberfirst.com/", "root-1", "post-2", "https://www.askaplumberfirst.com/questions/root-1#post-post-2"},
{"https://www.askaplumberfirst.com", "a b", "c/d", "https://www.askaplumberfirst.com/questions/a%20b#post-c%2Fd"},
}
for _, tc := range tests {
if got := Permalink(tc.base, tc.root, tc.post); got != tc.want {
t.Fatalf("Permalink(%q, %q, %q) = %q, want %q", tc.base, tc.root, tc.post, got, tc.want)
}
}
}
func TestNopAndRecording(t *testing.T) {
t.Parallel()
Nop{}.Publish(context.Background(), PostCreated{})
rec := &Recording{}
rec.Publish(context.Background(), PostCreated{PostEvent: PostEvent{PostID: "a"}})
rec.Publish(context.Background(), PostUpdated{PostEvent: PostEvent{PostID: "b"}})
if rec.Len() != 2 {
t.Fatalf("len = %d", rec.Len())
}
got := rec.Snapshot()
created, ok := got[0].(PostCreated)
if !ok || created.PostID != "a" {
t.Fatalf("first = %#v", got[0])
}
updated, ok := got[1].(PostUpdated)
if !ok || updated.PostID != "b" {
t.Fatalf("second = %#v", got[1])
}
}
func TestBusDeliversInOrder(t *testing.T) {
t.Parallel()
bus := New()
defer bus.Close()
var mu sync.Mutex
var got []string
done := make(chan struct{})
bus.Subscribe(func(_ context.Context, ev any) {
mu.Lock()
got = append(got, ev.(string))
if len(got) == 3 {
close(done)
}
mu.Unlock()
})
ctx := context.Background()
bus.Publish(ctx, "one")
bus.Publish(ctx, "two")
bus.Publish(ctx, "three")
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for events")
}
mu.Lock()
defer mu.Unlock()
if len(got) != 3 || got[0] != "one" || got[1] != "two" || got[2] != "three" {
t.Fatalf("got %v", got)
}
}
func TestBusDropsWhenFull(t *testing.T) {
t.Parallel()
bus := newBus(1, false)
bus.Publish(context.Background(), "kept")
bus.Publish(context.Background(), "dropped")
select {
case ev := <-bus.ch:
if ev != "kept" {
t.Fatalf("got %v", ev)
}
default:
t.Fatal("expected buffered event")
}
select {
case ev := <-bus.ch:
t.Fatalf("unexpected extra event %v", ev)
default:
}
}
+47
View File
@@ -0,0 +1,47 @@
package events
import (
"net/url"
"strings"
)
// Image is a public photo already attached to a site post.
type Image struct {
URL string
Description string
}
// PostEvent is a Discord-free snapshot of a site post after a successful write.
type PostEvent struct {
PostID string
RootID string
ParentID string
Title string
Body string
City string
AuthorID string
AuthorName string
AuthorRole string
Images []Image
Permalink string
}
// PostCreated is emitted after a successful site create.
type PostCreated struct {
PostEvent
}
// PostUpdated is emitted after a successful site edit.
type PostUpdated struct {
PostEvent
}
// Permalink builds /questions/{root}#post-{id}, prefixed by baseURL when set.
func Permalink(baseURL, rootID, postID string) string {
path := "/questions/" + url.PathEscape(rootID) + "#post-" + url.PathEscape(postID)
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
return path
}
return base + path
}
+9
View File
@@ -0,0 +1,9 @@
package events
import "context"
// Nop is a Publisher used when nothing is subscribed.
type Nop struct{}
// Publish discards ev.
func (Nop) Publish(context.Context, any) {}
+44
View File
@@ -0,0 +1,44 @@
package events
import (
"context"
"sync"
)
// Recording is a test Publisher that records events synchronously.
type Recording struct {
mu sync.Mutex
evs []any
}
// Publish appends ev.
func (r *Recording) Publish(_ context.Context, ev any) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.evs = append(r.evs, ev)
}
// Len returns the number of recorded events.
func (r *Recording) Len() int {
if r == nil {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
return len(r.evs)
}
// Snapshot returns a copy of recorded events.
func (r *Recording) Snapshot() []any {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
out := make([]any, len(r.evs))
copy(out, r.evs)
return out
}
+49 -33
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 {
// PostReply is the payload for notifying a post author of a direct reply.
type PostReply struct {
ToEmail string
ToName string
QuestionID string
QuestionTitle string
AnswerBody 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>
+20 -16
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{
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{
ToName: `<Sam & Pat>`,
QuestionID: "question-123",
QuestionTitle: `<b>Leaky sink</b>`,
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
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
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// DiscordLink is the bot-owned mapping from a site post to a Discord message.
type DiscordLink struct {
PostID string
MessageID string
ThreadID string
CreatedAt string
}
// DiscordLinkStore is the mapping table used only by the Discord subscriber.
// It is not part of Store.
type DiscordLinkStore interface {
GetByPostID(ctx context.Context, postID string) (*DiscordLink, error)
GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error)
GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error)
Upsert(ctx context.Context, link DiscordLink) error
}
// DiscordLinks implements DiscordLinkStore against Postgres.
type DiscordLinks struct {
db *sql.DB
}
// NewDiscordLinks wraps db. It is independent of Store.
func NewDiscordLinks(db *sql.DB) *DiscordLinks {
return &DiscordLinks{db: db}
}
// GetByPostID returns the link for a site post.
func (d *DiscordLinks) GetByPostID(ctx context.Context, postID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByPostID(ctx, strings.TrimSpace(postID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetByMessageID returns the link for a Discord message.
func (d *DiscordLinks) GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByMessageID(ctx, strings.TrimSpace(messageID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetRootByThreadID returns the root link for a Discord thread.
func (d *DiscordLinks) GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByThreadID(ctx, strings.TrimSpace(threadID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// Upsert inserts or replaces the Discord IDs for a post.
func (d *DiscordLinks) Upsert(ctx context.Context, link DiscordLink) error {
if d == nil || d.db == nil {
return fmt.Errorf("discord links: no database")
}
link.PostID = strings.TrimSpace(link.PostID)
link.MessageID = strings.TrimSpace(link.MessageID)
link.ThreadID = strings.TrimSpace(link.ThreadID)
if link.PostID == "" || link.MessageID == "" {
return fmt.Errorf("discord links: post and message ids are required")
}
if link.CreatedAt == "" {
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
return sqlc.New(d.db).UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: link.PostID,
DiscordMessageID: link.MessageID,
DiscordThreadID: link.ThreadID,
CreatedAt: link.CreatedAt,
})
}
func discordLinkFromRow(row sqlc.DiscordPostLink) *DiscordLink {
return &DiscordLink{
PostID: row.PostID,
MessageID: row.DiscordMessageID,
ThreadID: row.DiscordThreadID,
CreatedAt: row.CreatedAt,
}
}
+9 -195
View File
@@ -10,8 +10,6 @@ import (
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
@@ -19,9 +17,6 @@ type Memory struct {
mu sync.Mutex
users map[string]*User // id -> user
byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
@@ -31,9 +26,6 @@ func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
@@ -210,172 +202,6 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
return nil
}
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
m.mu.Lock()
defer m.mu.Unlock()
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
author, ok := m.users[q.AuthorID]
if !ok {
return fmt.Errorf("unknown author")
}
cp := *q
cp.AuthorName = author.Name
cp.db = nil
m.questions[cp.ID] = &cp
*q = cp
return nil
}
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
out := *q
score := 0
userVote := 0
if votes, ok := m.votes[q.ID]; ok {
for uid, v := range votes {
score += v
if uid == viewerID {
userVote = v
}
}
}
_, answered := m.answers[q.ID]
out.Score = score
out.Answered = answered
out.UserVote = userVote
out.db = nil
return out
}
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.annotate(q, viewerID)
return &out, nil
}
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.annotate(q, viewerID))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].CreatedAt < out[j].CreatedAt
})
if len(out) > HuntListLimit {
out = out[:HuntListLimit]
}
return out, nil
}
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.annotate(q, ""))
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
}
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.annotate(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
}
func (m *Memory) HideQuestion(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return sql.ErrNoRows
}
q.Hidden = true
return nil
}
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.answers[questionID]
if !ok {
return nil, sql.ErrNoRows
}
cp := *a
if u, ok := m.users[a.AuthorID]; ok {
cp.AuthorName = u.Name
}
return &cp, nil
}
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[a.QuestionID]; !ok {
return fmt.Errorf("unknown question")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[a.QuestionID]; ok {
a.CreatedAt = existing.CreatedAt
} else if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
cp := *a
cp.db = nil
m.answers[a.QuestionID] = &cp
*a = cp
return nil
}
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -383,6 +209,9 @@ func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if err := preparePost(post); err != nil {
return err
}
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
@@ -461,6 +290,10 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
post.Body = body
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
@@ -468,6 +301,7 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
return sql.ErrNoRows
}
existing.Body = body
existing.Images = append([]PostImage(nil), post.Images...)
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
@@ -625,6 +459,7 @@ func clonePost(post *Post) *Post {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Images = append([]PostImage(nil), post.Images...)
cp.Replies = nil
return &cp
}
@@ -637,24 +472,3 @@ func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
}
return cp
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
q, ok := m.questions[questionID]
if !ok || q.Hidden {
return ErrHiddenOrMissing
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
}
if value == 0 {
delete(m.votes[questionID], userID)
return nil
}
m.votes[questionID][userID] = value
return nil
}
+60 -28
View File
@@ -35,13 +35,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
return nil
}
// migratePosts creates the unified post model and snapshots legacy content.
// Legacy tables remain in place until the application cutover is complete.
// migratePosts creates the unified post model.
func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
args []any
}{
{name: "create posts", sql: `
CREATE TABLE IF NOT EXISTS posts (
@@ -78,33 +76,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id)
)`},
{name: "copy questions", sql: `
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
SELECT
id, NULL, author_id, title, body, city, hunt_date,
CASE WHEN hidden = 0 THEN $1 ELSE $2 END,
created_at, created_at
FROM questions
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}},
{name: "copy answers", sql: `
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
SELECT
'answer:' || question_id, question_id, author_id, '', body, '', '',
$1, created_at, updated_at
FROM answers
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}},
{name: "copy votes", sql: `
INSERT INTO post_votes (user_id, post_id, value)
SELECT user_id, question_id, value
FROM votes
ON CONFLICT (user_id, post_id) DO NOTHING`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql, step.args...); err != nil {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
@@ -129,6 +103,61 @@ CREATE INDEX IF NOT EXISTS idx_posts_author_created
return nil
}
func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"drop legacy answers", `DROP TABLE IF EXISTS answers`},
{"drop legacy votes", `DROP TABLE IF EXISTS votes`},
{"drop legacy questions", `DROP TABLE IF EXISTS questions`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostImages(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS post_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3),
width INTEGER NOT NULL CHECK (width > 0),
height INTEGER NOT NULL CHECK (height > 0),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
)`); err != nil {
return fmt.Errorf("create post images: %w", err)
}
return nil
}
func migrateDiscordPostLinks(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS discord_post_links (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
discord_message_id TEXT NOT NULL UNIQUE,
discord_thread_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create discord_post_links: %w", err)
}
if _, err := exec.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
ON discord_post_links (discord_thread_id)
WHERE discord_thread_id <> ''`); err != nil {
return fmt.Errorf("discord_post_links_thread_uidx: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -319,6 +348,9 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks},
}
for _, m := range migrations {
if applied[m.version] {
+152 -71
View File
@@ -13,7 +13,7 @@ import (
"plumber/internal/store/sqlc"
)
func TestMigratePostsCopiesLegacyData(t *testing.T) {
func TestPostMigrationsAndQueries(t *testing.T) {
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if rawURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
@@ -47,47 +47,13 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) {
t.Fatal(err)
}
legacySchema := `
baseSchema := `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE questions (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
hunt_date TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE answers (
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE votes (
user_id TEXT NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, question_id)
);`
if err := applySchema(ctx, conn, legacySchema); err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z');
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
INSERT INTO votes (user_id, question_id, value)
VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := applySchema(ctx, conn, baseSchema); err != nil {
t.Fatal(err)
}
@@ -103,28 +69,121 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index migration is not idempotent: %v", err)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatalf("post author index migration is not idempotent: %v", err)
}
if err := migratePostImages(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImages(ctx, conn); err != nil {
t.Fatalf("post images migration is not idempotent: %v", err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES
(
'root-1', NULL, 'homeowner', 'Leaky sink', 'It drips.', 'Oakland',
'2026-08-26', 'visible', '2026-08-26T08:00:00Z', '2026-08-26T08:00:00Z'
),
(
'reply-1', 'root-1', 'plumber', '', 'Replace the cartridge.', '', '',
'visible', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z'
);
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'root-1', 1);`); err != nil {
t.Fatal(err)
}
var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
var postCount, voteCount int
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
if postCount != 2 || voteCount != 1 {
t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
} {
if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
}
rootImages, err := imageQueries.ListPostImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
t.Fatalf(
"counts posts=%d votes=%d legacy questions=%d answers=%d",
postCount,
voteCount,
legacyQuestionCount,
legacyAnswerCount,
)
if len(rootImages) != 2 ||
rootImages[0].ID != "root-image-1" ||
rootImages[1].ID != "root-image-2" {
t.Fatalf("root images = %+v", rootImages)
}
threadImages, err := imageQueries.ListPostThreadImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(threadImages) != 3 {
t.Fatalf("thread images = %+v", threadImages)
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg",
PublicUrl: "https://cdn.example/root-5.jpg", Position: 4,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1",
DiscordMessageID: "msg-root",
DiscordThreadID: "thread-root",
CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:00:00Z",
}); err != nil {
t.Fatal(err)
}
rootLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "root-1")
if err != nil || rootLink.DiscordMessageID != "msg-root" || rootLink.DiscordThreadID != "thread-root" {
t.Fatalf("root discord link = %+v, %v", rootLink, err)
}
threadLink, err := imageQueries.GetDiscordPostLinkByThreadID(ctx, "thread-root")
if err != nil || threadLink.PostID != "root-1" {
t.Fatalf("thread discord link = %+v, %v", threadLink, err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply-2",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:01:00Z",
}); err != nil {
t.Fatal(err)
}
replyLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "reply-1")
if err != nil || replyLink.DiscordMessageID != "msg-reply-2" || replyLink.DiscordThreadID != "" {
t.Fatalf("reply upsert = %+v, %v", replyLink, err)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
@@ -152,7 +211,7 @@ WHERE schemaname = current_schema()
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil {
VALUES ('homeowner', 'root-1', -1)`); err == nil {
t.Fatal("duplicate user/post vote unexpectedly succeeded")
}
@@ -161,7 +220,7 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
FROM posts
WHERE id = 'question-1'`).Scan(
WHERE id = 'root-1'`).Scan(
&rootParent,
&rootAuthor,
&title,
@@ -190,7 +249,7 @@ WHERE id = 'question-1'`).Scan(
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, body, post_state, created_at, updated_at
FROM posts
WHERE id = 'answer:question-1'`).Scan(
WHERE id = 'reply-1'`).Scan(
&replyParent,
&replyAuthor,
&replyBody,
@@ -200,7 +259,7 @@ WHERE id = 'answer:question-1'`).Scan(
); err != nil {
t.Fatal(err)
}
if replyParent != "question-1" ||
if replyParent != "root-1" ||
replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." ||
replyState != "visible" ||
@@ -212,7 +271,7 @@ WHERE id = 'answer:question-1'`).Scan(
var voteValue int
if err := conn.QueryRowContext(ctx, `
SELECT value FROM post_votes
WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
WHERE user_id = 'homeowner' AND post_id = 'root-1'`).Scan(&voteValue); err != nil {
t.Fatal(err)
}
if voteValue != 1 {
@@ -223,7 +282,7 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err !
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES (
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'invalid-reply', 'root-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'visible', 'now', 'now'
)`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded")
@@ -232,7 +291,7 @@ INSERT INTO posts (
queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up",
ParentID: sql.NullString{String: "answer:question-1", Valid: true},
ParentID: sql.NullString{String: "reply-1", Valid: true},
AuthorID: "homeowner",
Body: "It is still dripping.",
PostState: string(PostStateVisible),
@@ -241,17 +300,17 @@ INSERT INTO posts (
}); err != nil {
t.Fatal(err)
}
thread, err := queries.ListPostThread(ctx, "question-1")
thread, err := queries.ListPostThread(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
thread[0].ID != "question-1" ||
thread[1].ID != "answer:question-1" ||
thread[0].ID != "root-1" ||
thread[1].ID != "reply-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
nonRootThread, err := queries.ListPostThread(ctx, "reply-1")
if err != nil {
t.Fatal(err)
}
@@ -267,7 +326,7 @@ INSERT INTO posts (
}
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber",
PostID: "question-1",
PostID: "root-1",
Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 {
@@ -290,7 +349,7 @@ INSERT INTO posts (
}
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber",
RootID: "question-1",
RootID: "root-1",
})
if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err)
@@ -300,7 +359,7 @@ INSERT INTO posts (
HiddenState: string(PostStateHidden),
RowLimit: 50,
})
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "question-1" {
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "root-1" {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
@@ -308,14 +367,14 @@ INSERT INTO posts (
AdminID: "plumber",
RowLimit: 50,
})
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "question-1" {
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "root-1" {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z",
ID: "question-1",
ID: "root-1",
})
if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
@@ -358,7 +417,7 @@ WHERE schemaname = current_schema()
}
var migratedPostDate string
if err := conn.QueryRowContext(ctx, `
SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
SELECT post_date FROM posts WHERE id = 'root-1'`).Scan(&migratedPostDate); err != nil {
t.Fatal(err)
}
if postDateColumnCount != 1 ||
@@ -380,7 +439,7 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e
DROP INDEX idx_posts_root_date;
ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END;
UPDATE posts SET hidden = CASE WHEN id = 'root-1' THEN 1 ELSE 0 END;
ALTER TABLE posts DROP COLUMN post_state;
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
@@ -411,11 +470,11 @@ WHERE table_schema = current_schema()
}
var hiddenState, replyStateAfterMigration string
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
SELECT post_state FROM posts WHERE id = 'root-1'`).Scan(&hiddenState); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil {
SELECT post_state FROM posts WHERE id = 'reply-1'`).Scan(&replyStateAfterMigration); err != nil {
t.Fatal(err)
}
var postStateDataType string
@@ -453,15 +512,37 @@ WHERE schemaname = current_schema()
stateIndexCount,
)
}
if _, err := conn.ExecContext(ctx, `
CREATE TABLE questions (id TEXT PRIMARY KEY);
CREATE TABLE votes (id TEXT PRIMARY KEY);
CREATE TABLE answers (id TEXT PRIMARY KEY);`); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatalf("legacy table cleanup is not idempotent: %v", err)
}
for _, table := range []string{"questions", "votes", "answers"} {
var relation sql.NullString
if err := conn.QueryRowContext(ctx, "SELECT to_regclass($1)", table).Scan(&relation); err != nil {
t.Fatal(err)
}
if relation.Valid {
t.Fatalf("legacy table %q still exists", table)
}
}
}
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
exec := &failingMigrationExec{failAt: 6}
exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "copy questions") {
t.Fatalf("error = %v, want copy questions context", err)
if err == nil || !strings.Contains(err.Error(), "create post votes") {
t.Fatalf("error = %v, want create post votes context", err)
}
}
+156 -7
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -27,8 +28,23 @@ const (
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxImageDescriptionRunes = 500
)
// PostImage is one ordered public image attached to a post.
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicURL string
Description string
Position int
Width int
Height int
CreatedAt string
}
// Post is either a root question (ParentID nil) or a reply to another post.
type Post struct {
ID string
@@ -46,6 +62,7 @@ type Post struct {
Score int
Answered bool
UserVote int
Images []PostImage
Replies []*Post
db *sql.DB
}
@@ -63,7 +80,16 @@ func (p *Post) Create(ctx context.Context) error {
if err := preparePost(p); err != nil {
return err
}
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
if err := q.CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
@@ -74,11 +100,16 @@ func (p *Post) Create(ctx context.Context) error {
PostState: string(p.PostState),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
}); err != nil {
return mapPostCreateError(err)
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
}
// Update changes only the post body and update timestamp.
// Update changes the post body, update timestamp, and complete image set.
func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
@@ -88,7 +119,16 @@ func (p *Post) Update(ctx context.Context) error {
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{
if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
n, err := q.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
@@ -99,7 +139,13 @@ func (p *Post) Update(ctx context.Context) error {
if n == 0 {
return sql.ErrNoRows
}
return nil
if err := q.DeletePostImages(ctx, p.ID); err != nil {
return err
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
}
func preparePost(p *Post) error {
@@ -153,6 +199,85 @@ func preparePost(p *Post) error {
return nil
}
func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano)
for i := range p.Images {
image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID)
image.PostID = strings.TrimSpace(image.PostID)
image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description)
if image.ID == "" {
image.ID = uuid.NewString()
}
if image.PostID == "" {
image.PostID = p.ID
}
if image.PostID != p.ID {
return fmt.Errorf("%w: image belongs to another post", ErrInvalidPost)
}
if image.ObjectKey == "" || image.PublicURL == "" {
return fmt.Errorf("%w: image storage metadata is required", ErrInvalidPost)
}
if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
}
if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost)
}
if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
}
ids[image.ID] = true
keys[image.ObjectKey] = true
image.Position = i
if image.CreatedAt == "" {
image.CreatedAt = now
}
}
return nil
}
func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage) error {
for _, image := range images {
if err := q.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL,
Description: image.Description,
Position: int16(image.Position),
Width: int32(image.Width),
Height: int32(image.Height),
CreatedAt: image.CreatedAt,
}); err != nil {
return err
}
}
return nil
}
func postImageFromSQL(image sqlc.PostImage) PostImage {
return PostImage{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl,
Description: image.Description,
Position: int(image.Position),
Width: int(image.Width),
Height: int(image.Height),
CreatedAt: image.CreatedAt,
}
}
func nullableParentID(parentID *string) sql.NullString {
if parentID == nil {
return sql.NullString{}
@@ -209,7 +334,8 @@ func postFromValues(
// 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)
q := sqlc.New(db)
r, err := q.GetPost(ctx, id)
if err != nil {
return nil, err
}
@@ -228,12 +354,20 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r.CreatedAt,
r.UpdatedAt,
)
imageRows, err := q.ListPostImages(ctx, id)
if err != nil {
return nil, err
}
for _, image := range imageRows {
p.Images = append(p.Images, postImageFromSQL(image))
}
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)
q := sqlc.New(db)
rows, err := q.ListPostThread(ctx, rootID)
if err != nil {
return nil, err
}
@@ -255,6 +389,21 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
r.UpdatedAt,
))
}
imageRows, err := q.ListPostThreadImages(ctx, rootID)
if err != nil {
return nil, err
}
postsByID := make(map[string]*Post, len(posts))
for i := range posts {
postsByID[posts[i].ID] = &posts[i]
}
for _, image := range imageRows {
post, ok := postsByID[image.PostID]
if !ok {
return nil, fmt.Errorf("image %s belongs to missing post %s", image.ID, image.PostID)
}
post.Images = append(post.Images, postImageFromSQL(image))
}
return buildPostTree(posts, rootID)
}
+87
View File
@@ -267,6 +267,93 @@ func TestMemoryPostValidation(t *testing.T) {
}
}
func TestMemoryPostImages(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "images", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
root := &Post{
ID: "image-root",
AuthorID: homeowner.ID,
Title: "What is leaking?",
Body: "Here are two photos.",
Images: []PostImage{
{ID: "image-a", ObjectKey: "posts/image-root/image-a.jpg", PublicURL: "https://cdn.example/image-a.jpg", Description: " Supply valve ", Position: 3, Width: 1200, Height: 900},
{ID: "image-b", ObjectKey: "posts/image-root/image-b.png", PublicURL: "https://cdn.example/image-b.png", Width: 900, Height: 1200},
},
}
if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err)
}
if root.Images[0].Position != 0 ||
root.Images[1].Position != 1 ||
root.Images[0].PostID != root.ID ||
root.Images[0].Description != "Supply valve" {
t.Fatalf("created images were not normalized: %+v", root.Images)
}
loaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
loaded.Images[0].Description = "mutated outside store"
reloaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.Images[0].Description != "Supply valve" {
t.Fatalf("stored image mutated through clone: %+v", reloaded.Images[0])
}
reloaded.Body = "Updated photos."
reloaded.Images = []PostImage{
reloaded.Images[1],
{ID: "image-c", ObjectKey: "posts/image-root/image-c.jpg", PublicURL: "https://cdn.example/image-c.jpg", Description: "Trap connection", Width: 1600, Height: 1000},
}
if err := mem.UpdatePost(ctx, reloaded); err != nil {
t.Fatal(err)
}
updated, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if len(updated.Images) != 2 ||
updated.Images[0].ID != "image-b" ||
updated.Images[0].Position != 0 ||
updated.Images[1].ID != "image-c" ||
updated.Images[1].Position != 1 {
t.Fatalf("updated images = %+v", updated.Images)
}
tooMany := &Post{
AuthorID: homeowner.ID,
Title: "Too many",
Body: "Five photos.",
Images: validPostImages(5),
}
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
images := make([]PostImage, count)
for i := range images {
images[i] = PostImage{
ID: "image-" + string(rune('a'+i)),
ObjectKey: "posts/key-" + string(rune('a'+i)) + ".jpg",
PublicURL: "https://cdn.example/" + string(rune('a'+i)) + ".jpg",
Width: 100,
Height: 100,
}
}
return images
}
func ptr(value string) *string {
return &value
}
-39
View File
@@ -109,41 +109,6 @@ func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
return u.SaveProfile(ctx)
}
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
q.db = p.db
return q.Create(ctx)
}
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
return GetQuestion(ctx, p.db, id, viewerID)
}
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
return ListHunt(ctx, p.db, huntDate, viewerID)
}
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
return ListQuestionsByAuthor(ctx, p.db, authorID)
}
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
}
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
q := &RankedQuestion{ID: id, db: p.db}
return q.Hide(ctx)
}
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
return GetAnswer(ctx, p.db, questionID)
}
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
a.db = p.db
return a.Upsert(ctx)
}
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
@@ -185,7 +150,3 @@ func (p *Postgres) SetRootPostState(ctx context.Context, id string, state PostSt
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value)
}
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
return Vote(ctx, p.db, userID, questionID, value)
}
-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
}
+100
View File
@@ -0,0 +1,100 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: discord_links.sql
package sqlc
import (
"context"
)
const getDiscordPostLinkByMessageID = `-- name: GetDiscordPostLinkByMessageID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_message_id = $1
`
func (q *Queries) GetDiscordPostLinkByMessageID(ctx context.Context, discordMessageID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByMessageID, discordMessageID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByPostID = `-- name: GetDiscordPostLinkByPostID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE post_id = $1
`
func (q *Queries) GetDiscordPostLinkByPostID(ctx context.Context, postID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByPostID, postID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByThreadID = `-- name: GetDiscordPostLinkByThreadID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_thread_id = $1
AND discord_thread_id <> ''
`
func (q *Queries) GetDiscordPostLinkByThreadID(ctx context.Context, discordThreadID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByThreadID, discordThreadID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const upsertDiscordPostLink = `-- name: UpsertDiscordPostLink :exec
INSERT INTO discord_post_links (
post_id, discord_message_id, discord_thread_id, created_at
)
VALUES (
$1,
$2,
$3,
$4
)
ON CONFLICT (post_id) DO UPDATE SET
discord_message_id = EXCLUDED.discord_message_id,
discord_thread_id = CASE
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
ELSE discord_post_links.discord_thread_id
END
`
type UpsertDiscordPostLinkParams struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
func (q *Queries) UpsertDiscordPostLink(ctx context.Context, arg UpsertDiscordPostLinkParams) error {
_, err := q.db.ExecContext(ctx, upsertDiscordPostLink,
arg.PostID,
arg.DiscordMessageID,
arg.DiscordThreadID,
arg.CreatedAt,
)
return err
}
+16 -22
View File
@@ -9,12 +9,11 @@ import (
"time"
)
type Answer struct {
QuestionID string
AuthorID string
Body string
type DiscordPostLink struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
UpdatedAt string
}
type Post struct {
@@ -30,23 +29,24 @@ type Post struct {
UpdatedAt string
}
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
type PostVote struct {
UserID string
PostID string
Value int32
}
type Question struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
}
type Session struct {
Token string
Data []byte
@@ -64,9 +64,3 @@ type User struct {
State string
CreatedAt string
}
type Vote struct {
UserID string
QuestionID string
Value int32
}
+148
View File
@@ -57,6 +57,60 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
return err
}
const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
`
type CreatePostImageParams struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams) error {
_, err := q.db.ExecContext(ctx, createPostImage,
arg.ID,
arg.PostID,
arg.ObjectKey,
arg.PublicUrl,
arg.Description,
arg.Position,
arg.Width,
arg.Height,
arg.CreatedAt,
)
return err
}
const deletePostImages = `-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = $1
`
func (q *Queries) DeletePostImages(ctx context.Context, postID string) error {
_, err := q.db.ExecContext(ctx, deletePostImages, postID)
return err
}
const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = $1
@@ -145,6 +199,47 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
return i, err
}
const listPostImages = `-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = $1
ORDER BY position
`
func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostImages, postID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); 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 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
@@ -218,6 +313,59 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
return items, nil
}
const listPostThreadImages = `-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position
`
func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostThreadImages, rootID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); 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
-316
View File
@@ -1,316 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: questions.sql
package sqlc
import (
"context"
)
const createQuestion = `-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`
type CreateQuestionParams struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
CreatedAt string
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
_, err := q.db.ExecContext(ctx, createQuestion,
arg.ID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.HuntDate,
arg.CreatedAt,
)
return err
}
const getQuestion = `-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = $2
`
type GetQuestionParams struct {
ViewerID string
ID string
}
type GetQuestionRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID)
var i GetQuestionRow
err := row.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
)
return i, err
}
const hideQuestion = `-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1
`
func (q *Queries) HideQuestion(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, hideQuestion, id)
return err
}
const listHunt = `-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = $2 AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC
LIMIT $3
`
type ListHuntParams struct {
ViewerID string
HuntDate string
RowLimit int32
}
type ListHuntRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListHuntRow{}
for rows.Next() {
var i ListHuntRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = $1 AND q.hidden = 0
ORDER BY ans.updated_at DESC
LIMIT $2
`
type ListQuestionsAnsweredByParams struct {
AdminID string
RowLimit int32
}
type ListQuestionsAnsweredByRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsAnsweredByRow{}
for rows.Next() {
var i ListQuestionsAnsweredByRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = $1 AND q.hidden = 0
ORDER BY q.created_at DESC
LIMIT $2
`
type ListQuestionsByAuthorParams struct {
AuthorID string
RowLimit int32
}
type ListQuestionsByAuthorRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsByAuthorRow{}
for rows.Next() {
var i ListQuestionsByAuthorRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-82
View File
@@ -1,82 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: votes.sql
package sqlc
import (
"context"
)
const deleteVote = `-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2
`
type DeleteVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
_, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
return err
}
const getVote = `-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2
`
type GetVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
var value int32
err := row.Scan(&value)
return value, err
}
const questionIsVisible = `-- name: QuestionIsVisible :one
SELECT EXISTS(
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
)::bool
`
func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRowContext(ctx, questionIsVisible, id)
var column_1 bool
err := row.Scan(&column_1)
return column_1, err
}
const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows
INSERT INTO votes (user_id, question_id, value)
SELECT $1, $2, $3
FROM questions q
WHERE q.id = $2 AND q.hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
WHERE EXISTS (
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
)
`
type UpsertVoteOnVisibleParams struct {
UserID string
QuestionID string
Value int32
}
func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-13
View File
@@ -27,16 +27,6 @@ type Store interface {
SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error
CreateQuestion(ctx context.Context, q *RankedQuestion) error
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
HideQuestion(ctx context.Context, id string) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *Answer) error
CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID string) (*Post, error)
@@ -47,7 +37,4 @@ type Store interface {
ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) error
VotePost(ctx context.Context, userID, postID string, value int) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
Vote(ctx context.Context, userID, questionID string, value int) error
}
+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
}
+65
View File
@@ -0,0 +1,65 @@
package web
import (
"context"
"plumber/internal/events"
"plumber/internal/store"
)
func (s *Server) publishPostCreated(post, root *store.Post, author *store.User) {
s.publishPost(events.PostCreated{PostEvent: s.postEvent(post, root, author)}, root)
}
func (s *Server) publishPostUpdated(post, root *store.Post, author *store.User) {
s.publishPost(events.PostUpdated{PostEvent: s.postEvent(post, root, author)}, root)
}
func (s *Server) publishPost(ev any, root *store.Post) {
if root != nil && root.PostState == store.PostStateHidden {
return
}
s.cfg.Events.Publish(context.Background(), ev)
}
func (s *Server) postEvent(post, root *store.Post, author *store.User) events.PostEvent {
if post == nil {
return events.PostEvent{}
}
rootID := post.ID
if root != nil {
rootID = root.ID
}
ev := events.PostEvent{
PostID: post.ID,
RootID: rootID,
Title: post.Title,
Body: post.Body,
City: post.City,
AuthorID: post.AuthorID,
AuthorName: post.AuthorName,
AuthorRole: string(post.AuthorRole),
Permalink: events.Permalink(s.cfg.BaseURL, rootID, post.ID),
}
if post.ParentID != nil {
ev.ParentID = *post.ParentID
}
if author != nil {
if ev.AuthorName == "" {
ev.AuthorName = author.Name
}
if ev.AuthorRole == "" {
ev.AuthorRole = string(author.Role)
}
}
if n := len(post.Images); n > 0 {
ev.Images = make([]events.Image, 0, n)
for _, img := range post.Images {
ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL,
Description: img.Description,
})
}
}
return ev
}
+238
View File
@@ -0,0 +1,238 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestPostHandlersPublishEvents(t *testing.T) {
t.Parallel()
rec := &events.Recording{}
srv, mem := newTestServer(t, Config{
Events: rec,
BaseURL: "https://www.askaplumberfirst.com",
})
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)
submit := postForm(handler, "/submit", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"Water under the cabinet."},
"city": {"Oakland"},
}, homeownerCookies)
if submit.Code != http.StatusSeeOther {
t.Fatalf("submit status = %d: %s", submit.Code, submit.Body.String())
}
create := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Second question"},
"body": {"Another leak."},
"city": {"Berkeley"},
}, homeownerCookies)
if create.Code != http.StatusSeeOther {
t.Fatalf("create status = %d: %s", create.Code, create.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 2 {
t.Fatalf("roots = %+v, %v", roots, err)
}
var submitRoot, createRoot store.Post
for _, root := range roots {
switch root.Title {
case "Leaky sink":
submitRoot = root
case "Second question":
createRoot = root
}
}
if submitRoot.ID == "" || createRoot.ID == "" {
t.Fatalf("missing created roots: %+v", roots)
}
reply := postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {createRoot.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if reply.Code != http.StatusSeeOther {
t.Fatalf("reply status = %d: %s", reply.Code, reply.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), createRoot.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
adminReply := thread.Replies[0]
edit := postForm(handler, "/posts/"+createRoot.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Updated leak description."},
}, homeownerCookies)
if edit.Code != http.StatusSeeOther {
t.Fatalf("edit status = %d: %s", edit.Code, edit.Body.String())
}
empty := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Missing body"},
}, homeownerCookies)
if empty.Code != http.StatusBadRequest {
t.Fatalf("empty body status = %d, want 400", empty.Code)
}
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden thread",
Body: "Not public.",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
hiddenReply := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {hidden.ID},
"body": {"Should not publish."},
}, homeownerCookies)
if hiddenReply.Code != http.StatusNotFound {
t.Fatalf("hidden reply status = %d, want 404", hiddenReply.Code)
}
hiddenEdit := postForm(handler, "/posts/"+hidden.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Still hidden."},
}, homeownerCookies)
if hiddenEdit.Code != http.StatusSeeOther {
t.Fatalf("hidden edit status = %d: %s", hiddenEdit.Code, hiddenEdit.Body.String())
}
got := rec.Snapshot()
if len(got) != 4 {
t.Fatalf("published %d events, want 4: %#v", len(got), got)
}
submitEv, ok := got[0].(events.PostCreated)
if !ok {
t.Fatalf("first event %T, want PostCreated", got[0])
}
assertPostEvent(t, submitEv.PostEvent, events.PostEvent{
PostID: submitRoot.ID,
RootID: submitRoot.ID,
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + submitRoot.ID + "#post-" + submitRoot.ID,
})
createEv, ok := got[1].(events.PostCreated)
if !ok {
t.Fatalf("second event %T, want PostCreated", got[1])
}
assertPostEvent(t, createEv.PostEvent, events.PostEvent{
PostID: createRoot.ID,
RootID: createRoot.ID,
Title: "Second question",
Body: "Another leak.",
City: "Berkeley",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
})
replyEv, ok := got[2].(events.PostCreated)
if !ok {
t.Fatalf("third event %T, want PostCreated", got[2])
}
assertPostEvent(t, replyEv.PostEvent, events.PostEvent{
PostID: adminReply.ID,
RootID: createRoot.ID,
ParentID: createRoot.ID,
Body: "Replace the cartridge.",
AuthorID: admin.ID,
AuthorName: admin.Name,
AuthorRole: string(store.RoleAdmin),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + adminReply.ID,
})
editEv, ok := got[3].(events.PostUpdated)
if !ok {
t.Fatalf("fourth event %T, want PostUpdated", got[3])
}
assertPostEvent(t, editEv.PostEvent, events.PostEvent{
PostID: createRoot.ID,
RootID: createRoot.ID,
Title: "Second question",
Body: "Updated leak description.",
City: "Berkeley",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
})
for i, ev := range got {
raw, err := json.Marshal(ev)
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(raw)), "discord") {
t.Fatalf("event %d contains discord fields: %s", i, raw)
}
}
}
func TestStoreCreateDoesNotPublish(t *testing.T) {
t.Parallel()
rec := &events.Recording{}
_, mem := newTestServer(t, Config{Events: rec})
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
if err := mem.CreatePost(context.Background(), &store.Post{
AuthorID: homeowner.ID,
Title: "Direct write",
Body: "No handler.",
PostDate: pacific.Today(),
}); err != nil {
t.Fatal(err)
}
if rec.Len() != 0 {
t.Fatalf("store.CreatePost published %d events", rec.Len())
}
}
func assertPostEvent(t *testing.T, got, want events.PostEvent) {
t.Helper()
if got.PostID != want.PostID ||
got.RootID != want.RootID ||
got.ParentID != want.ParentID ||
got.Title != want.Title ||
got.Body != want.Body ||
got.City != want.City ||
got.AuthorID != want.AuthorID ||
got.AuthorName != want.AuthorName ||
got.AuthorRole != want.AuthorRole ||
got.Permalink != want.Permalink ||
len(got.Images) != 0 {
t.Fatalf("event = %+v, want %+v", got, want)
}
}
+64 -2
View File
@@ -5,12 +5,15 @@ import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -37,7 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
AuthorID: user.ID,
Body: truncateRunes(body, 12000),
}
var root *store.Post
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)
@@ -46,7 +49,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
return
}
} else {
parent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
@@ -63,6 +66,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
parent = loadedParent
post.ParentID = &parent.ID
root = threadRoot
}
@@ -78,6 +82,10 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil {
root = post
}
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
s.publishPostCreated(post, root, user)
http.Redirect(
w,
r,
@@ -86,6 +94,59 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
)
}
// 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) {
@@ -130,6 +191,7 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save post", http.StatusInternalServerError)
return
}
s.publishPostUpdated(post, root, nil)
http.Redirect(
w,
r,
+160
View File
@@ -7,7 +7,9 @@ import (
"net/url"
"strings"
"testing"
"time"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -258,6 +260,143 @@ func TestEditPostRoutePermissions(t *testing.T) {
}
}
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()
@@ -319,8 +458,13 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
"Shop response",
"Edited",
`action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`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')`,
} {
@@ -328,6 +472,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
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)
}
@@ -345,6 +492,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
}
}
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)
+7 -42
View File
@@ -20,6 +20,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
@@ -35,6 +36,8 @@ type Config struct {
TrustedProxies []*net.IPNet
Blob blob.Uploader
Mail mail.Notifier
Events events.Publisher
BaseURL string
}
type Server struct {
@@ -110,6 +113,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
if cfg.Events == nil {
cfg.Events = events.Nop{}
}
funcMap := template.FuncMap{
"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}
@@ -196,7 +202,6 @@ func (s *Server) Handler() http.Handler {
r.Post("/submit", s.handleSubmit)
r.Get("/questions/{id}", s.handleQuestion)
r.Post("/questions/{id}/vote", s.handleVote)
r.Post("/questions/{id}/answer", s.handleAnswer)
r.Post("/questions/{id}/hide", s.handleHide)
r.Post("/posts", s.handleCreatePost)
r.Post("/posts/{id}/edit", s.handleEditPost)
@@ -372,6 +377,7 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save question", http.StatusInternalServerError)
return
}
s.publishPostCreated(post, post, u)
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
}
@@ -478,47 +484,6 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
})
}
func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
u := currentUser(r)
if !u.Admin() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
id := chi.URLParam(r, "id")
body := strings.TrimSpace(r.PostFormValue("body"))
if body == "" {
http.Error(w, "answer required", http.StatusBadRequest)
return
}
if len(body) > 12000 {
body = truncateRunes(body, 12000)
}
root, err := s.store.GetPost(r.Context(), id)
if err != nil || root.ParentID != nil || root.PostState == store.PostStateHidden {
http.NotFound(w, r)
return
}
reply := &store.Post{
ParentID: &root.ID,
AuthorID: u.ID,
Body: body,
}
if err := s.store.CreatePost(r.Context(), reply); err != nil {
http.Error(w, "could not save answer", http.StatusInternalServerError)
return
}
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
if isHTMX(r) {
w.Header().Set("HX-Redirect", location)
w.WriteHeader(http.StatusSeeOther)
return
}
http.Redirect(w, r, location, http.StatusSeeOther)
}
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
+15 -79
View File
@@ -168,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)
@@ -482,12 +492,12 @@ 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")
@@ -560,85 +570,11 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
t.Fatalf("vote not applied: %+v %v", got, err)
}
// Non-admin answer rejected
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", nil)
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Nope")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range userCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
}
// Admin answer compatibility route creates a reply and redirects the thread.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range adminCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), q.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("admin reply missing: %+v %v", thread, err)
}
adminReply := thread.Replies[0]
if adminReply.AuthorID != admin.ID || adminReply.Body != "Tighten the nuts." {
t.Fatalf("unexpected admin reply: %+v", adminReply)
}
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
t.Fatalf("admin answer redirect = %q", got)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if body := rec.Body.String(); !strings.Contains(body, "Tighten the nuts.") ||
!strings.Contains(body, "<summary>Edit</summary>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="post-composer" open`) {
t.Fatalf("admin reply editor is not collapsed and populated: %s", body)
}
// The public reply is visible to the root author, but editing remains admin-only.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), `/posts/`+adminReply.ID+`/edit`) {
t.Fatalf("question author can edit admin reply: %s", rec.Body.String())
if rec.Code != http.StatusNotFound {
t.Fatalf("removed answer endpoint want 404, got %d", rec.Code)
}
// Hide invalid id
+24 -28
View File
@@ -14,34 +14,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> '';
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
hunt_date TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_questions_hunt_date ON questions(hunt_date, hidden);
CREATE TABLE IF NOT EXISTS votes (
user_id TEXT NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, question_id)
);
CREATE TABLE IF NOT EXISTS answers (
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
@@ -70,6 +42,19 @@ 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_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3),
width INTEGER NOT NULL CHECK (width > 0),
height INTEGER NOT NULL CHECK (height > 0),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
);
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,
@@ -80,6 +65,17 @@ CREATE TABLE IF NOT EXISTS post_votes (
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id);
CREATE TABLE IF NOT EXISTS discord_post_links (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
discord_message_id TEXT NOT NULL UNIQUE,
discord_thread_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
ON discord_post_links (discord_thread_id)
WHERE discord_thread_id <> '';
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
+40
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);
@@ -718,6 +749,11 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; }
.post-permalink {
display: inline-flex;
align-items: center;
}
.post-hide {
margin: 0;
}
@@ -763,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">
+6 -2
View File
@@ -3,13 +3,14 @@
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts">
<form class="post-form" method="post" action="/posts"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="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">Post reply</button>
<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>
@@ -32,6 +33,9 @@
</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}}">
+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" .}}