Author SHA1 Message Date
codegirl007 eb4cc08a69 Prevent duplicate post submissions
CI / test (pull_request) Successful in 6m16s
2026-08-27 09:16:05 -07:00
codegirl007 17e73ad2d3 Notify homeowners of plumber replies
CI / test (pull_request) Successful in 6m16s
2026-08-27 09:12:30 -07:00
codegirl007 df17e0e6f3 Remove legacy question storage
CI / test (pull_request) Failing after 5m44s
2026-08-27 09:08:40 -07:00
37 changed files with 79 additions and 2944 deletions
-4
View File
@@ -26,7 +26,3 @@ 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
+3 -15
View File
@@ -17,8 +17,6 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/discord"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
"plumber/internal/web"
@@ -36,16 +34,7 @@ func main() {
if err != nil {
log.Fatalf("mail: %v", err)
}
bus := events.New()
mail.Subscribe(bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db), notifier)
if err != nil {
log.Fatalf("discord: %v", err)
}
if bot != nil {
defer bot.Close()
}
handler := newHandler(db, sessions, uploader, bus)
handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -69,14 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, bus events.Publisher) http.Handler {
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
Mail: notifier,
})
if err != nil {
log.Fatalf("server: %v", err)
-32
View File
@@ -1,32 +0,0 @@
-- 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,52 +61,6 @@ 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
+1 -4
View File
@@ -7,12 +7,10 @@ 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
)
@@ -27,11 +25,10 @@ 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,8 +24,6 @@ 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=
@@ -33,8 +31,6 @@ 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=
@@ -54,22 +50,14 @@ 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
@@ -1,102 +0,0 @@
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
}
-198
View File
@@ -1,198 +0,0 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/mail"
"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
store store.Store
mail mail.Notifier
admins map[string]string
botUserID string
}
// 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, st store.Store, mailer mail.Notifier) (*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
}
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
if mailer == nil {
mailer = mail.Nop{}
}
bot := New(channelID, links, &sessionAPI{session: session})
bot.store = st
bot.mail = mailer
bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP"))
session.AddHandler(bot.onMessageCreate)
if bus != nil {
bus.Subscribe(bot.Handle)
}
if err := session.Open(); err != nil {
_ = session.Close()
return nil, fmt.Errorf("discord gateway: %w", err)
}
if session.State != nil && session.State.User != nil {
bot.botUserID = session.State.User.ID
}
log.Printf("discord: 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
@@ -1,247 +0,0 @@
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, nil, 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, nil, 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, nil, 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
@@ -1,75 +0,0 @@
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) == ""
}
-227
View File
@@ -1,227 +0,0 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/mail"
"plumber/internal/store"
)
const inboundBodyLimit = 12000
type inboundMessage struct {
ID string
ChannelID string
GuildID string
AuthorID string
Content string
ReferencedMessageID string
Bot bool
Attachments int
}
func parseAdminMap(raw string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, username, ok := strings.Cut(part, ":")
id = strings.TrimSpace(id)
username = store.NormalizeUsername(username)
if !ok || id == "" || username == "" {
log.Printf("discord: skip invalid DISCORD_ADMIN_MAP entry %q", part)
continue
}
out[id] = username
}
return out
}
func (b *Bot) onMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
if b == nil || m == nil || m.Author == nil {
return
}
in := inboundMessage{
ID: m.ID,
ChannelID: m.ChannelID,
GuildID: m.GuildID,
AuthorID: m.Author.ID,
Content: m.Content,
Bot: m.Author.Bot,
Attachments: len(m.Attachments),
}
if m.MessageReference != nil {
in.ReferencedMessageID = m.MessageReference.MessageID
}
b.handleInbound(in)
}
func (b *Bot) handleInbound(in inboundMessage) {
if b == nil || b.store == nil {
return
}
if in.Bot || strings.TrimSpace(in.GuildID) == "" {
return
}
if b.botUserID != "" && in.AuthorID == b.botUserID {
return
}
body := strings.TrimSpace(in.Content)
if in.Attachments > 0 {
log.Printf("discord: ignoring %d attachment(s) on %s", in.Attachments, in.ID)
}
if body == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discordTimeout)
defer cancel()
if !b.knownChannel(ctx, in.ChannelID) {
return
}
username := b.admins[in.AuthorID]
if username == "" {
return
}
author, err := b.store.UserByUsername(ctx, username)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound author %s: %v", username, err)
}
return
}
if !author.Admin() {
log.Printf("discord: inbound %s is not an admin", username)
return
}
parent, root, err := b.inboundParent(ctx, in)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound parent %s: %v", in.ID, err)
}
return
}
if root.PostState == store.PostStateHidden {
return
}
reply := &store.Post{
AuthorID: author.ID,
Body: truncateRunes(body, inboundBodyLimit),
ParentID: &parent.ID,
}
if err := b.store.CreatePost(ctx, reply); err != nil {
log.Printf("discord: create inbound %s: %v", in.ID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: reply.ID,
MessageID: in.ID,
}); err != nil {
log.Printf("discord: save inbound link %s: %v", reply.ID, err)
}
b.notifyInboundReply(parent, root, reply, author)
log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID)
}
func (b *Bot) knownChannel(ctx context.Context, channelID string) bool {
if strings.TrimSpace(channelID) == "" {
return false
}
if channelID == b.channelID {
return true
}
_, err := b.links.GetRootByThreadID(ctx, channelID)
return err == nil
}
func (b *Bot) inboundParent(ctx context.Context, in inboundMessage) (*store.Post, *store.Post, error) {
if ref := strings.TrimSpace(in.ReferencedMessageID); ref != "" {
link, err := b.links.GetByMessageID(ctx, ref)
if err == nil {
return b.postAndRoot(ctx, link.PostID)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, nil, err
}
}
link, err := b.links.GetRootByThreadID(ctx, in.ChannelID)
if err != nil {
return nil, nil, err
}
return b.postAndRoot(ctx, link.PostID)
}
func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) {
postID = strings.TrimSpace(postID)
if postID == "" {
return nil, nil, sql.ErrNoRows
}
post, err := b.store.GetPost(ctx, postID)
if err != nil {
return nil, nil, err
}
current := post
seen := map[string]bool{}
for current.ParentID != nil {
if seen[current.ID] {
return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID)
}
seen[current.ID] = true
current, err = b.store.GetPost(ctx, *current.ParentID)
if err != nil {
return nil, nil, err
}
}
return post, current, nil
}
func (b *Bot) notifyInboundReply(parent, root, reply *store.Post, author *store.User) {
if parent == nil || root == nil || reply == nil || author == nil || b.mail == nil {
return
}
if _, disabled := b.mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: author.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := b.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 := b.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
-218
View File
@@ -1,218 +0,0 @@
package discord
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestParseAdminMap(t *testing.T) {
t.Parallel()
got := parseAdminMap(" 123:Plumber ,456:other,bad, :empty,789: ")
if got["123"] != "plumber" || got["456"] != "other" || len(got) != 2 {
t.Fatalf("parseAdminMap = %#v", got)
}
}
func TestInboundCreatesSiteReply(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
mailer := &mail.Recording{}
bot := inboundTestBot(mem, links, api, mailer, admin.Username)
bus := events.New()
defer bus.Close()
bus.Subscribe(bot.Handle)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
bot.handleInbound(inboundMessage{
ID: "d-reply-1",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "Replace the cartridge.",
})
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
reply := thread.Replies[0]
if reply.AuthorID != admin.ID || reply.Body != "Replace the cartridge." || reply.ParentID == nil || *reply.ParentID != root.ID {
t.Fatalf("reply = %+v", reply)
}
link, err := links.GetByPostID(context.Background(), reply.ID)
if err != nil || link.MessageID != "d-reply-1" || link.ThreadID != "" {
t.Fatalf("inbound link = %+v, %v", link, err)
}
time.Sleep(20 * time.Millisecond)
if len(api.sends) != 0 || len(api.edits) != 0 {
t.Fatalf("inbound echoed to Discord: sends=%+v edits=%+v", api.sends, api.edits)
}
msgs := waitForMail(t, mailer, 1)
if msgs[0].ToEmail != homeowner.Email || msgs[0].ReplyID != reply.ID || msgs[0].RootID != root.ID {
t.Fatalf("mail = %+v", msgs[0])
}
}
func TestInboundParentsFromReference(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
bot := inboundTestBot(mem, links, &fakeAPI{}, &mail.Recording{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."}
if err := mem.CreatePost(context.Background(), plumberReply); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: plumberReply.ID,
MessageID: "d-plumber-1",
}); err != nil {
t.Fatal(err)
}
bot.handleInbound(inboundMessage{
ID: "d-nested",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "More detail.",
ReferencedMessageID: "d-plumber-1",
})
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
nested := thread.Replies[0].Replies[0]
if nested.ParentID == nil || *nested.ParentID != plumberReply.ID {
t.Fatalf("nested parent = %+v", nested)
}
}
func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
bot := inboundTestBot(mem, links, api, mail.Nop{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden",
Body: "No.",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: hidden.ID,
MessageID: "d-hidden",
ThreadID: "thread-hidden",
}); err != nil {
t.Fatal(err)
}
cases := []inboundMessage{
{ID: "bot", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: "x", Bot: true},
{ID: "dm", ChannelID: "thread-1", AuthorID: "snow-admin", Content: "x"},
{ID: "self", ChannelID: "thread-1", GuildID: "g", AuthorID: "bot-1", Content: "x"},
{ID: "stranger", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-other", Content: "x"},
{ID: "elsewhere", ChannelID: "other-thread", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "empty", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: " ", Attachments: 1},
{ID: "hidden", ChannelID: "thread-hidden", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "channel-root", ChannelID: "channel-1", GuildID: "g", AuthorID: "snow-admin", Content: "new question"},
}
for _, in := range cases {
bot.handleInbound(in)
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 0 {
t.Fatalf("unexpected replies: %+v, %v", thread, err)
}
if len(api.sends) != 0 {
t.Fatalf("unexpected discord sends %+v", api.sends)
}
}
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, mailer mail.Notifier, adminUsername string) *Bot {
bot := New("channel-1", links, api)
bot.store = mem
bot.mail = mailer
bot.admins = map[string]string{"snow-admin": adminUsername}
bot.botUserID = "bot-1"
return bot
}
func seedInboundUsers(t *testing.T) (*store.Memory, *store.User, *store.User) {
t.Helper()
mem := store.NewMemory()
homeowner := &store.User{
Username: "homeowner",
Name: "Sam",
Email: "sam@example.com",
PasswordHash: "x",
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), homeowner); err != nil {
t.Fatal(err)
}
admin := &store.User{
Username: "plumber",
Name: "Pat",
Email: "pat@example.com",
PasswordHash: "x",
Role: store.RoleAdmin,
}
if err := mem.CreateUser(context.Background(), admin); err != nil {
t.Fatal(err)
}
return mem, homeowner, admin
}
func seedLinkedRoot(t *testing.T, mem *store.Memory, links *memoryLinks, authorID, threadID string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: "Leaky sink",
Body: "It drips.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: root.ID,
MessageID: "d-root",
ThreadID: threadID,
}); err != nil {
t.Fatal(err)
}
return root
}
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
}
-88
View File
@@ -1,88 +0,0 @@
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
@@ -1,79 +0,0 @@
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
@@ -1,104 +0,0 @@
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
@@ -1,47 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,44 +0,0 @@
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
}
-89
View File
@@ -1,89 +0,0 @@
package mail
import (
"context"
"log"
"strings"
"time"
"plumber/internal/events"
"plumber/internal/store"
)
// Subscribe sends reply emails from PostCreated events. Nop or nil is a no-op.
func Subscribe(bus *events.Bus, st store.Store, n Notifier) {
if bus == nil || st == nil || n == nil {
return
}
if _, disabled := n.(Nop); disabled {
return
}
s := subscriber{store: st, mail: n}
bus.Subscribe(s.handle)
}
type subscriber struct {
store store.Store
mail Notifier
}
func (s subscriber) handle(_ context.Context, ev any) {
created, ok := ev.(events.PostCreated)
if !ok {
return
}
if strings.TrimSpace(created.ParentID) == "" {
return
}
go s.notifyReply(created.PostEvent)
}
func (s subscriber) notifyReply(ev events.PostEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
parent, err := s.store.GetPost(ctx, ev.ParentID)
if err != nil {
log.Printf("notify reply %s: load parent: %v", ev.PostID, err)
return
}
root, err := s.store.GetPost(ctx, ev.RootID)
if err != nil {
log.Printf("notify reply %s: load root: %v", ev.PostID, err)
return
}
author, err := s.store.UserByID(ctx, ev.AuthorID)
if err != nil {
log.Printf("notify reply %s: load author: %v", ev.PostID, err)
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: ev.PostID,
ReplyBody: ev.Body,
ReplyAuthorName: author.Name,
}
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.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}
-200
View File
@@ -1,200 +0,0 @@
package mail
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestSubscribeReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
adminReply := seedMailReply(t, mem, admin.ID, root.ID)
homeownerReply := seedMailReply(t, mem, homeowner.ID, adminReply.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, recording)
ctx := context.Background()
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: root.ID,
RootID: root.ID,
Title: root.Title,
Body: root.Body,
AuthorID: homeowner.ID,
}})
bus.Publish(ctx, events.PostUpdated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: "Edited",
AuthorID: admin.ID,
}})
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: adminReply.Body,
AuthorID: admin.ID,
}})
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 = %+v", msg)
}
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: homeownerReply.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: homeownerReply.Body,
AuthorID: homeowner.ID,
}})
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 = %+v", msg)
}
nestedAdmin := seedMailReply(t, mem, admin.ID, adminReply.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: nestedAdmin.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: nestedAdmin.Body,
AuthorID: admin.ID,
}})
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != nestedAdmin.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply = %+v", msg)
}
self := seedMailReply(t, mem, homeowner.ID, root.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: self.ID,
RootID: root.ID,
ParentID: root.ID,
Body: self.Body,
AuthorID: homeowner.ID,
}})
noEmail := seedMailUser(t, mem, "quiet", store.RoleUser, "")
quietRoot := seedMailRoot(t, mem, noEmail.ID, "Quiet thread", "No email.")
quietReply := seedMailReply(t, mem, admin.ID, quietRoot.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: quietReply.ID,
RootID: quietRoot.ID,
ParentID: quietRoot.ID,
Body: quietReply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, root, edit, or no-email sent mail: %+v", recording.Snapshot())
}
}
func TestSubscribeNopIgnoresReplies(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
reply := seedMailReply(t, mem, admin.ID, root.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, Nop{})
Subscribe(nil, mem, recording)
Subscribe(bus, mem, nil)
bus.Publish(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: reply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 0 {
t.Fatalf("Nop or nil subscribe sent mail: %+v", recording.Snapshot())
}
}
func seedMailUser(t *testing.T, mem *store.Memory, username string, role store.Role, email string) *store.User {
t.Helper()
u := &store.User{
Username: username,
Name: username,
Email: email,
PasswordHash: "x",
Role: role,
}
if err := mem.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
return u
}
func seedMailRoot(t *testing.T, mem *store.Memory, authorID, title, body string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: title,
Body: body,
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
return root
}
func seedMailReply(t *testing.T, mem *store.Memory, authorID, parentID string) *store.Post {
t.Helper()
reply := &store.Post{
ParentID: &parentID,
AuthorID: authorID,
Body: "Reply from " + authorID,
}
if err := mem.CreatePost(context.Background(), reply); err != nil {
t.Fatal(err)
}
return reply
}
func waitForMail(t *testing.T, recording *Recording, want int) []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
}
-105
View File
@@ -1,105 +0,0 @@
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
View File
@@ -209,9 +209,6 @@ 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 {
@@ -290,10 +287,6 @@ 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]
@@ -301,7 +294,6 @@ 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
@@ -459,7 +451,6 @@ func clonePost(post *Post) *Post {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Images = append([]PostImage(nil), post.Images...)
cp.Replies = nil
return &cp
}
-40
View File
@@ -120,44 +120,6 @@ func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
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
@@ -349,8 +311,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"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] {
-81
View File
@@ -75,18 +75,6 @@ CREATE TABLE users (
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');
@@ -116,75 +104,6 @@ VALUES ('homeowner', 'root-1', 1);`); 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)
}
}
rootImages, err := imageQueries.ListPostImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
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, `
SELECT count(*)
+11 -160
View File
@@ -5,7 +5,6 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -25,26 +24,11 @@ var (
type PostState string
const (
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxImageDescriptionRunes = 500
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
)
// 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
@@ -62,7 +46,6 @@ type Post struct {
Score int
Answered bool
UserVote int
Images []PostImage
Replies []*Post
db *sql.DB
}
@@ -80,16 +63,7 @@ func (p *Post) Create(ctx context.Context) error {
if err := preparePost(p); err != nil {
return err
}
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{
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
@@ -100,16 +74,11 @@ 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()
})
return mapPostCreateError(err)
}
// Update changes the post body, update timestamp, and complete image set.
// Update changes only the post body and update timestamp.
func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
@@ -119,16 +88,7 @@ func (p *Post) Update(ctx context.Context) error {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
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{
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
@@ -139,13 +99,7 @@ func (p *Post) Update(ctx context.Context) error {
if n == 0 {
return sql.ErrNoRows
}
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()
return nil
}
func preparePost(p *Post) error {
@@ -199,85 +153,6 @@ 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{}
@@ -334,8 +209,7 @@ func postFromValues(
// GetPost returns one post without loading its replies.
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
q := sqlc.New(db)
r, err := q.GetPost(ctx, id)
r, err := sqlc.New(db).GetPost(ctx, id)
if err != nil {
return nil, err
}
@@ -354,20 +228,12 @@ 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) {
q := sqlc.New(db)
rows, err := q.ListPostThread(ctx, rootID)
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
if err != nil {
return nil, err
}
@@ -389,21 +255,6 @@ 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,93 +267,6 @@ 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
}
-100
View File
@@ -1,100 +0,0 @@
// 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
}
-19
View File
@@ -9,13 +9,6 @@ import (
"time"
)
type DiscordPostLink struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
type Post struct {
ID string
ParentID sql.NullString
@@ -29,18 +22,6 @@ 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
-148
View File
@@ -57,60 +57,6 @@ 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
@@ -199,47 +145,6 @@ 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
@@ -313,59 +218,6 @@ 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
-65
View File
@@ -1,65 +0,0 @@
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
@@ -1,238 +0,0 @@
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)
}
}
+59 -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"
)
@@ -79,7 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil {
root = post
}
s.publishPostCreated(post, root, user)
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
http.Redirect(
w,
r,
@@ -88,6 +93,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) {
@@ -132,7 +190,6 @@ 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,
+1 -12
View File
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
@@ -264,12 +263,8 @@ func TestEditPostRoutePermissions(t *testing.T) {
func TestPostReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
bus := events.New()
defer bus.Close()
recording := &mail.Recording{}
mail.Subscribe(bus, mem, recording)
srv := newTestServerStore(t, mem, Config{Events: bus})
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)
@@ -467,9 +462,6 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`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')`,
} {
@@ -477,9 +469,6 @@ 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)
}
+4 -6
View File
@@ -20,8 +20,8 @@ import (
"github.com/go-chi/chi/v5/middleware"
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -34,8 +34,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet
Blob blob.Uploader
Events events.Publisher
BaseURL string
Mail mail.Notifier
}
type Server struct {
@@ -108,8 +107,8 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
}
if cfg.Events == nil {
cfg.Events = events.Nop{}
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
@@ -372,7 +371,6 @@ 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)
}
-24
View File
@@ -42,19 +42,6 @@ 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,
@@ -65,17 +52,6 @@ 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,
-5
View File
@@ -749,11 +749,6 @@ 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;
}
-3
View File
@@ -33,9 +33,6 @@
</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}}">