This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// DiscordLink is the bot-owned mapping from a site post to a Discord message.
|
||||
type DiscordLink struct {
|
||||
PostID string
|
||||
MessageID string
|
||||
ThreadID string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// DiscordLinkStore is the mapping table used only by the Discord subscriber.
|
||||
// It is not part of Store.
|
||||
type DiscordLinkStore interface {
|
||||
GetByPostID(ctx context.Context, postID string) (*DiscordLink, error)
|
||||
GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error)
|
||||
GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error)
|
||||
Upsert(ctx context.Context, link DiscordLink) error
|
||||
}
|
||||
|
||||
// DiscordLinks implements DiscordLinkStore against Postgres.
|
||||
type DiscordLinks struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDiscordLinks wraps db. It is independent of Store.
|
||||
func NewDiscordLinks(db *sql.DB) *DiscordLinks {
|
||||
return &DiscordLinks{db: db}
|
||||
}
|
||||
|
||||
// GetByPostID returns the link for a site post.
|
||||
func (d *DiscordLinks) GetByPostID(ctx context.Context, postID string) (*DiscordLink, error) {
|
||||
if d == nil || d.db == nil {
|
||||
return nil, fmt.Errorf("discord links: no database")
|
||||
}
|
||||
row, err := sqlc.New(d.db).GetDiscordPostLinkByPostID(ctx, strings.TrimSpace(postID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return discordLinkFromRow(row), nil
|
||||
}
|
||||
|
||||
// GetByMessageID returns the link for a Discord message.
|
||||
func (d *DiscordLinks) GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error) {
|
||||
if d == nil || d.db == nil {
|
||||
return nil, fmt.Errorf("discord links: no database")
|
||||
}
|
||||
row, err := sqlc.New(d.db).GetDiscordPostLinkByMessageID(ctx, strings.TrimSpace(messageID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return discordLinkFromRow(row), nil
|
||||
}
|
||||
|
||||
// GetRootByThreadID returns the root link for a Discord thread.
|
||||
func (d *DiscordLinks) GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error) {
|
||||
if d == nil || d.db == nil {
|
||||
return nil, fmt.Errorf("discord links: no database")
|
||||
}
|
||||
row, err := sqlc.New(d.db).GetDiscordPostLinkByThreadID(ctx, strings.TrimSpace(threadID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return discordLinkFromRow(row), nil
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces the Discord IDs for a post.
|
||||
func (d *DiscordLinks) Upsert(ctx context.Context, link DiscordLink) error {
|
||||
if d == nil || d.db == nil {
|
||||
return fmt.Errorf("discord links: no database")
|
||||
}
|
||||
link.PostID = strings.TrimSpace(link.PostID)
|
||||
link.MessageID = strings.TrimSpace(link.MessageID)
|
||||
link.ThreadID = strings.TrimSpace(link.ThreadID)
|
||||
if link.PostID == "" || link.MessageID == "" {
|
||||
return fmt.Errorf("discord links: post and message ids are required")
|
||||
}
|
||||
if link.CreatedAt == "" {
|
||||
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return sqlc.New(d.db).UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
|
||||
PostID: link.PostID,
|
||||
DiscordMessageID: link.MessageID,
|
||||
DiscordThreadID: link.ThreadID,
|
||||
CreatedAt: link.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func discordLinkFromRow(row sqlc.DiscordPostLink) *DiscordLink {
|
||||
return &DiscordLink{
|
||||
PostID: row.PostID,
|
||||
MessageID: row.DiscordMessageID,
|
||||
ThreadID: row.DiscordThreadID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,25 @@ CREATE TABLE IF NOT EXISTS post_images (
|
||||
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
|
||||
@@ -331,6 +350,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
{"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,6 +81,12 @@ CREATE TABLE users (
|
||||
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');
|
||||
@@ -143,6 +149,42 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
|
||||
}); 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(*)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: discord_links.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getDiscordPostLinkByMessageID = `-- name: GetDiscordPostLinkByMessageID :one
|
||||
SELECT post_id, discord_message_id, discord_thread_id, created_at
|
||||
FROM discord_post_links
|
||||
WHERE discord_message_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetDiscordPostLinkByMessageID(ctx context.Context, discordMessageID string) (DiscordPostLink, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByMessageID, discordMessageID)
|
||||
var i DiscordPostLink
|
||||
err := row.Scan(
|
||||
&i.PostID,
|
||||
&i.DiscordMessageID,
|
||||
&i.DiscordThreadID,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDiscordPostLinkByPostID = `-- name: GetDiscordPostLinkByPostID :one
|
||||
SELECT post_id, discord_message_id, discord_thread_id, created_at
|
||||
FROM discord_post_links
|
||||
WHERE post_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetDiscordPostLinkByPostID(ctx context.Context, postID string) (DiscordPostLink, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByPostID, postID)
|
||||
var i DiscordPostLink
|
||||
err := row.Scan(
|
||||
&i.PostID,
|
||||
&i.DiscordMessageID,
|
||||
&i.DiscordThreadID,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDiscordPostLinkByThreadID = `-- name: GetDiscordPostLinkByThreadID :one
|
||||
SELECT post_id, discord_message_id, discord_thread_id, created_at
|
||||
FROM discord_post_links
|
||||
WHERE discord_thread_id = $1
|
||||
AND discord_thread_id <> ''
|
||||
`
|
||||
|
||||
func (q *Queries) GetDiscordPostLinkByThreadID(ctx context.Context, discordThreadID string) (DiscordPostLink, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByThreadID, discordThreadID)
|
||||
var i DiscordPostLink
|
||||
err := row.Scan(
|
||||
&i.PostID,
|
||||
&i.DiscordMessageID,
|
||||
&i.DiscordThreadID,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertDiscordPostLink = `-- name: UpsertDiscordPostLink :exec
|
||||
INSERT INTO discord_post_links (
|
||||
post_id, discord_message_id, discord_thread_id, created_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
)
|
||||
ON CONFLICT (post_id) DO UPDATE SET
|
||||
discord_message_id = EXCLUDED.discord_message_id,
|
||||
discord_thread_id = CASE
|
||||
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
|
||||
ELSE discord_post_links.discord_thread_id
|
||||
END
|
||||
`
|
||||
|
||||
type UpsertDiscordPostLinkParams struct {
|
||||
PostID string
|
||||
DiscordMessageID string
|
||||
DiscordThreadID string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertDiscordPostLink(ctx context.Context, arg UpsertDiscordPostLinkParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertDiscordPostLink,
|
||||
arg.PostID,
|
||||
arg.DiscordMessageID,
|
||||
arg.DiscordThreadID,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiscordPostLink struct {
|
||||
PostID string
|
||||
DiscordMessageID string
|
||||
DiscordThreadID string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string
|
||||
ParentID sql.NullString
|
||||
|
||||
Reference in New Issue
Block a user