Remove legacy question storage (#7)

Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-27 16:17:57 +00:00
committed by codegirl007
parent f0591ccea3
commit f420f888af
26 changed files with 268 additions and 1401 deletions
-63
View File
@@ -1,63 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// Answer is an admin reply to a question.
type Answer struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
db *sql.DB
}
// NewAnswer returns an Answer bound to db.
func NewAnswer(db *sql.DB) *Answer {
return &Answer{db: db}
}
// Upsert inserts or updates the answer for QuestionID.
func (a *Answer) Upsert(ctx context.Context) error {
if a == nil || a.db == nil {
return fmt.Errorf("answer: no database")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{
QuestionID: a.QuestionID,
AuthorID: a.AuthorID,
Body: a.Body,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
r, err := sqlc.New(db).GetAnswer(ctx, questionID)
if err != nil {
return nil, err
}
return &Answer{
QuestionID: r.QuestionID,
AuthorID: r.AuthorID,
AuthorName: r.AuthorName,
Body: r.Body,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
db: db,
}, nil
}
+4 -199
View File
@@ -10,20 +10,15 @@ import (
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
type Memory struct {
mu sync.Mutex
users map[string]*User // id -> user
byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
users map[string]*User // id -> user
byName map[string]string // username -> id
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
@@ -31,9 +26,6 @@ func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
@@ -210,172 +202,6 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
return nil
}
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
m.mu.Lock()
defer m.mu.Unlock()
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
author, ok := m.users[q.AuthorID]
if !ok {
return fmt.Errorf("unknown author")
}
cp := *q
cp.AuthorName = author.Name
cp.db = nil
m.questions[cp.ID] = &cp
*q = cp
return nil
}
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
out := *q
score := 0
userVote := 0
if votes, ok := m.votes[q.ID]; ok {
for uid, v := range votes {
score += v
if uid == viewerID {
userVote = v
}
}
}
_, answered := m.answers[q.ID]
out.Score = score
out.Answered = answered
out.UserVote = userVote
out.db = nil
return out
}
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.annotate(q, viewerID)
return &out, nil
}
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.annotate(q, viewerID))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].CreatedAt < out[j].CreatedAt
})
if len(out) > HuntListLimit {
out = out[:HuntListLimit]
}
return out, nil
}
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.annotate(q, ""))
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
}
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.annotate(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
if len(out) > ProfileListLimit {
out = out[:ProfileListLimit]
}
return out, nil
}
func (m *Memory) HideQuestion(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return sql.ErrNoRows
}
q.Hidden = true
return nil
}
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.answers[questionID]
if !ok {
return nil, sql.ErrNoRows
}
cp := *a
if u, ok := m.users[a.AuthorID]; ok {
cp.AuthorName = u.Name
}
return &cp, nil
}
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[a.QuestionID]; !ok {
return fmt.Errorf("unknown question")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[a.QuestionID]; ok {
a.CreatedAt = existing.CreatedAt
} else if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
cp := *a
cp.db = nil
m.answers[a.QuestionID] = &cp
*a = cp
return nil
}
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -637,24 +463,3 @@ func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
}
return cp
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
q, ok := m.questions[questionID]
if !ok || q.Hidden {
return ErrHiddenOrMissing
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
}
if value == 0 {
delete(m.votes[questionID], userID)
return nil
}
m.votes[questionID][userID] = value
return nil
}
+20 -28
View File
@@ -35,13 +35,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
return nil
}
// migratePosts creates the unified post model and snapshots legacy content.
// Legacy tables remain in place until the application cutover is complete.
// migratePosts creates the unified post model.
func migratePosts(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
args []any
}{
{name: "create posts", sql: `
CREATE TABLE IF NOT EXISTS posts (
@@ -78,33 +76,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, post_id)
)`},
{name: "copy questions", sql: `
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
SELECT
id, NULL, author_id, title, body, city, hunt_date,
CASE WHEN hidden = 0 THEN $1 ELSE $2 END,
created_at, created_at
FROM questions
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}},
{name: "copy answers", sql: `
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
SELECT
'answer:' || question_id, question_id, author_id, '', body, '', '',
$1, created_at, updated_at
FROM answers
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}},
{name: "copy votes", sql: `
INSERT INTO post_votes (user_id, post_id, value)
SELECT user_id, question_id, value
FROM votes
ON CONFLICT (user_id, post_id) DO NOTHING`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql, step.args...); err != nil {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
@@ -129,6 +103,23 @@ CREATE INDEX IF NOT EXISTS idx_posts_author_created
return nil
}
func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"drop legacy answers", `DROP TABLE IF EXISTS answers`},
{"drop legacy votes", `DROP TABLE IF EXISTS votes`},
{"drop legacy questions", `DROP TABLE IF EXISTS questions`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -319,6 +310,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
}
for _, m := range migrations {
if applied[m.version] {
+75 -75
View File
@@ -13,7 +13,7 @@ import (
"plumber/internal/store/sqlc"
)
func TestMigratePostsCopiesLegacyData(t *testing.T) {
func TestPostMigrationsAndQueries(t *testing.T) {
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if rawURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
@@ -47,47 +47,13 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) {
t.Fatal(err)
}
legacySchema := `
baseSchema := `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE questions (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
city TEXT NOT NULL DEFAULT '',
hunt_date TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE answers (
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE votes (
user_id TEXT NOT NULL REFERENCES users(id),
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
PRIMARY KEY (user_id, question_id)
);`
if err := applySchema(ctx, conn, legacySchema); err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z');
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
INSERT INTO votes (user_id, question_id, value)
VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := applySchema(ctx, conn, baseSchema); err != nil {
t.Fatal(err)
}
@@ -103,28 +69,40 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index migration is not idempotent: %v", err)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatalf("post author index migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES
(
'root-1', NULL, 'homeowner', 'Leaky sink', 'It drips.', 'Oakland',
'2026-08-26', 'visible', '2026-08-26T08:00:00Z', '2026-08-26T08:00:00Z'
),
(
'reply-1', 'root-1', 'plumber', '', 'Replace the cartridge.', '', '',
'visible', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z'
);
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'root-1', 1);`); err != nil {
t.Fatal(err)
}
var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
var postCount, voteCount int
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
t.Fatal(err)
}
if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
t.Fatalf(
"counts posts=%d votes=%d legacy questions=%d answers=%d",
postCount,
voteCount,
legacyQuestionCount,
legacyAnswerCount,
)
if postCount != 2 || voteCount != 1 {
t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
@@ -152,7 +130,7 @@ WHERE schemaname = current_schema()
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil {
VALUES ('homeowner', 'root-1', -1)`); err == nil {
t.Fatal("duplicate user/post vote unexpectedly succeeded")
}
@@ -161,7 +139,7 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
FROM posts
WHERE id = 'question-1'`).Scan(
WHERE id = 'root-1'`).Scan(
&rootParent,
&rootAuthor,
&title,
@@ -190,7 +168,7 @@ WHERE id = 'question-1'`).Scan(
if err := conn.QueryRowContext(ctx, `
SELECT parent_id, author_id, body, post_state, created_at, updated_at
FROM posts
WHERE id = 'answer:question-1'`).Scan(
WHERE id = 'reply-1'`).Scan(
&replyParent,
&replyAuthor,
&replyBody,
@@ -200,7 +178,7 @@ WHERE id = 'answer:question-1'`).Scan(
); err != nil {
t.Fatal(err)
}
if replyParent != "question-1" ||
if replyParent != "root-1" ||
replyAuthor != "plumber" ||
replyBody != "Replace the cartridge." ||
replyState != "visible" ||
@@ -212,7 +190,7 @@ WHERE id = 'answer:question-1'`).Scan(
var voteValue int
if err := conn.QueryRowContext(ctx, `
SELECT value FROM post_votes
WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
WHERE user_id = 'homeowner' AND post_id = 'root-1'`).Scan(&voteValue); err != nil {
t.Fatal(err)
}
if voteValue != 1 {
@@ -223,7 +201,7 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err !
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
) VALUES (
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'invalid-reply', 'root-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
'visible', 'now', 'now'
)`); err == nil {
t.Fatal("reply with root-only title unexpectedly succeeded")
@@ -232,7 +210,7 @@ INSERT INTO posts (
queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up",
ParentID: sql.NullString{String: "answer:question-1", Valid: true},
ParentID: sql.NullString{String: "reply-1", Valid: true},
AuthorID: "homeowner",
Body: "It is still dripping.",
PostState: string(PostStateVisible),
@@ -241,17 +219,17 @@ INSERT INTO posts (
}); err != nil {
t.Fatal(err)
}
thread, err := queries.ListPostThread(ctx, "question-1")
thread, err := queries.ListPostThread(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
thread[0].ID != "question-1" ||
thread[1].ID != "answer:question-1" ||
thread[0].ID != "root-1" ||
thread[1].ID != "reply-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
nonRootThread, err := queries.ListPostThread(ctx, "reply-1")
if err != nil {
t.Fatal(err)
}
@@ -267,7 +245,7 @@ INSERT INTO posts (
}
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: "plumber",
PostID: "question-1",
PostID: "root-1",
Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 {
@@ -290,7 +268,7 @@ INSERT INTO posts (
}
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber",
RootID: "question-1",
RootID: "root-1",
})
if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err)
@@ -300,7 +278,7 @@ INSERT INTO posts (
HiddenState: string(PostStateHidden),
RowLimit: 50,
})
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "question-1" {
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "root-1" {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
@@ -308,14 +286,14 @@ INSERT INTO posts (
AdminID: "plumber",
RowLimit: 50,
})
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "question-1" {
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "root-1" {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z",
ID: "question-1",
ID: "root-1",
})
if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
@@ -358,7 +336,7 @@ WHERE schemaname = current_schema()
}
var migratedPostDate string
if err := conn.QueryRowContext(ctx, `
SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
SELECT post_date FROM posts WHERE id = 'root-1'`).Scan(&migratedPostDate); err != nil {
t.Fatal(err)
}
if postDateColumnCount != 1 ||
@@ -380,7 +358,7 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e
DROP INDEX idx_posts_root_date;
ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END;
UPDATE posts SET hidden = CASE WHEN id = 'root-1' THEN 1 ELSE 0 END;
ALTER TABLE posts DROP COLUMN post_state;
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
(parent_id IS NULL AND title <> '' AND post_date <> '')
@@ -411,11 +389,11 @@ WHERE table_schema = current_schema()
}
var hiddenState, replyStateAfterMigration string
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
SELECT post_state FROM posts WHERE id = 'root-1'`).Scan(&hiddenState); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil {
SELECT post_state FROM posts WHERE id = 'reply-1'`).Scan(&replyStateAfterMigration); err != nil {
t.Fatal(err)
}
var postStateDataType string
@@ -453,15 +431,37 @@ WHERE schemaname = current_schema()
stateIndexCount,
)
}
if _, err := conn.ExecContext(ctx, `
CREATE TABLE questions (id TEXT PRIMARY KEY);
CREATE TABLE votes (id TEXT PRIMARY KEY);
CREATE TABLE answers (id TEXT PRIMARY KEY);`); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDropLegacyPostTables(ctx, conn); err != nil {
t.Fatalf("legacy table cleanup is not idempotent: %v", err)
}
for _, table := range []string{"questions", "votes", "answers"} {
var relation sql.NullString
if err := conn.QueryRowContext(ctx, "SELECT to_regclass($1)", table).Scan(&relation); err != nil {
t.Fatal(err)
}
if relation.Valid {
t.Fatalf("legacy table %q still exists", table)
}
}
}
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
exec := &failingMigrationExec{failAt: 6}
exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "copy questions") {
t.Fatalf("error = %v, want copy questions context", err)
if err == nil || !strings.Contains(err.Error(), "create post votes") {
t.Fatalf("error = %v, want create post votes context", err)
}
}
-39
View File
@@ -109,41 +109,6 @@ func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
return u.SaveProfile(ctx)
}
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
q.db = p.db
return q.Create(ctx)
}
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
return GetQuestion(ctx, p.db, id, viewerID)
}
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
return ListHunt(ctx, p.db, huntDate, viewerID)
}
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
return ListQuestionsByAuthor(ctx, p.db, authorID)
}
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
}
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
q := &RankedQuestion{ID: id, db: p.db}
return q.Hide(ctx)
}
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
return GetAnswer(ctx, p.db, questionID)
}
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
a.db = p.db
return a.Upsert(ctx)
}
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
@@ -185,7 +150,3 @@ func (p *Postgres) SetRootPostState(ctx context.Context, id string, state PostSt
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value)
}
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
return Vote(ctx, p.db, userID, questionID, value)
}
-156
View File
@@ -1,156 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
"plumber/internal/store/sqlc"
)
// RankedQuestion is a question row with score / vote annotations for lists.
type RankedQuestion struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden bool
CreatedAt string
Score int
Answered bool
UserVote int
db *sql.DB
}
// NewQuestion returns a question bound to db (not yet inserted).
func NewQuestion(db *sql.DB) *RankedQuestion {
return &RankedQuestion{db: db}
}
// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty.
func (q *RankedQuestion) Create(ctx context.Context) error {
if q == nil || q.db == nil {
return fmt.Errorf("question: no database")
}
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{
ID: q.ID,
AuthorID: q.AuthorID,
Title: q.Title,
Body: q.Body,
City: q.City,
HuntDate: q.HuntDate,
CreatedAt: q.CreatedAt,
})
}
// Hide marks the question hidden.
func (q *RankedQuestion) Hide(ctx context.Context) error {
if q == nil || q.db == nil {
return fmt.Errorf("question: no database")
}
if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil {
return err
}
q.Hidden = true
return nil
}
func rankedFrom(
db *sql.DB,
id, authorID, authorName, title, body, city, huntDate, createdAt string,
hidden int32, score, answered, userVote int64,
) RankedQuestion {
return RankedQuestion{
ID: id,
AuthorID: authorID,
AuthorName: authorName,
Title: title,
Body: body,
City: city,
HuntDate: huntDate,
Hidden: hidden != 0,
CreatedAt: createdAt,
Score: int(score),
Answered: answered != 0,
UserVote: int(userVote),
db: db,
}
}
func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
ViewerID: viewerID,
HuntDate: huntDate,
RowLimit: HuntListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{
ViewerID: viewerID,
ID: id,
})
if err != nil {
return nil, err
}
q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)
return &q, nil
}
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{
AuthorID: authorID,
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{
AdminID: adminID,
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
-66
View File
@@ -1,66 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: answers.sql
package sqlc
import (
"context"
)
const getAnswer = `-- name: GetAnswer :one
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1
`
type GetAnswerRow struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) {
row := q.db.QueryRowContext(ctx, getAnswer, questionID)
var i GetAnswerRow
err := row.Scan(
&i.QuestionID,
&i.AuthorID,
&i.AuthorName,
&i.Body,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAnswer = `-- name: UpsertAnswer :exec
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at
`
type UpsertAnswerParams struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error {
_, err := q.db.ExecContext(ctx, upsertAnswer,
arg.QuestionID,
arg.AuthorID,
arg.Body,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
-25
View File
@@ -9,14 +9,6 @@ import (
"time"
)
type Answer struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
type Post struct {
ID string
ParentID sql.NullString
@@ -36,17 +28,6 @@ type PostVote struct {
Value int32
}
type Question struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
}
type Session struct {
Token string
Data []byte
@@ -64,9 +45,3 @@ type User struct {
State string
CreatedAt string
}
type Vote struct {
UserID string
QuestionID string
Value int32
}
-316
View File
@@ -1,316 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: questions.sql
package sqlc
import (
"context"
)
const createQuestion = `-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`
type CreateQuestionParams struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
CreatedAt string
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
_, err := q.db.ExecContext(ctx, createQuestion,
arg.ID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.HuntDate,
arg.CreatedAt,
)
return err
}
const getQuestion = `-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = $2
`
type GetQuestionParams struct {
ViewerID string
ID string
}
type GetQuestionRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID)
var i GetQuestionRow
err := row.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
)
return i, err
}
const hideQuestion = `-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1
`
func (q *Queries) HideQuestion(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, hideQuestion, id)
return err
}
const listHunt = `-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = $1 AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = $2 AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC
LIMIT $3
`
type ListHuntParams struct {
ViewerID string
HuntDate string
RowLimit int32
}
type ListHuntRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListHuntRow{}
for rows.Next() {
var i ListHuntRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = $1 AND q.hidden = 0
ORDER BY ans.updated_at DESC
LIMIT $2
`
type ListQuestionsAnsweredByParams struct {
AdminID string
RowLimit int32
}
type ListQuestionsAnsweredByRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsAnsweredByRow{}
for rows.Next() {
var i ListQuestionsAnsweredByRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = $1 AND q.hidden = 0
ORDER BY q.created_at DESC
LIMIT $2
`
type ListQuestionsByAuthorParams struct {
AuthorID string
RowLimit int32
}
type ListQuestionsByAuthorRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsByAuthorRow{}
for rows.Next() {
var i ListQuestionsByAuthorRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
-82
View File
@@ -1,82 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: votes.sql
package sqlc
import (
"context"
)
const deleteVote = `-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2
`
type DeleteVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
_, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
return err
}
const getVote = `-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2
`
type GetVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
var value int32
err := row.Scan(&value)
return value, err
}
const questionIsVisible = `-- name: QuestionIsVisible :one
SELECT EXISTS(
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
)::bool
`
func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRowContext(ctx, questionIsVisible, id)
var column_1 bool
err := row.Scan(&column_1)
return column_1, err
}
const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows
INSERT INTO votes (user_id, question_id, value)
SELECT $1, $2, $3
FROM questions q
WHERE q.id = $2 AND q.hidden = 0
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
WHERE EXISTS (
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
)
`
type UpsertVoteOnVisibleParams struct {
UserID string
QuestionID string
Value int32
}
func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-13
View File
@@ -27,16 +27,6 @@ type Store interface {
SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error
CreateQuestion(ctx context.Context, q *RankedQuestion) error
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
HideQuestion(ctx context.Context, id string) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *Answer) error
CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID string) (*Post, error)
@@ -47,7 +37,4 @@ type Store interface {
ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) error
VotePost(ctx context.Context, userID, postID string, value int) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
Vote(ctx context.Context, userID, questionID string, value int) error
}
+20 -2
View File
@@ -9,12 +9,19 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
// ErrLastAdmin is returned when demoting the only remaining admin.
var ErrLastAdmin = errors.New("cannot demote the last admin")
var (
// ErrLastAdmin is returned when demoting the only remaining admin.
ErrLastAdmin = errors.New("cannot demote the last admin")
// ErrDuplicateUsername is returned when inserting an existing username.
ErrDuplicateUsername = errors.New("username taken")
// ErrDuplicateEmail is returned when inserting or updating an existing email.
ErrDuplicateEmail = errors.New("email taken")
)
// Role is a user privilege level stored in users.role.
type Role string
@@ -51,6 +58,17 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
return ErrDuplicateEmail
}
return ErrDuplicateUsername
}
return err
}
func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
-71
View File
@@ -1,71 +0,0 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/store/sqlc"
)
// ErrDuplicateUsername is returned when inserting a username that already exists.
var ErrDuplicateUsername = errors.New("username taken")
// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
var ErrDuplicateEmail = errors.New("email taken")
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
var ErrHiddenOrMissing = errors.New("question not votable")
// SetVote sets the user's vote to value (1, -1, or 0 to clear) on a visible question.
func SetVote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
q := sqlc.New(db)
if value == 0 {
visible, err := q.QuestionIsVisible(ctx, questionID)
if err != nil {
return err
}
if !visible {
return ErrHiddenOrMissing
}
return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
UserID: userID,
QuestionID: questionID,
})
}
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
UserID: userID,
QuestionID: questionID,
Value: int32(value),
})
if err != nil {
return mapUniqueViolation(err)
}
if n == 0 {
return ErrHiddenOrMissing
}
return nil
}
// Vote is kept as an alias for SetVote for callers that still use the old name.
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
return SetVote(ctx, db, userID, questionID, value)
}
func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
return ErrDuplicateEmail
}
return ErrDuplicateUsername
}
return err
}