diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..deeaa84 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: sqlc +sqlc: + sqlc generate + +.PHONY: sqlc-check +sqlc-check: + sqlc diff diff --git a/db/queries/answers.sql b/db/queries/answers.sql new file mode 100644 index 0000000..bd1c59d --- /dev/null +++ b/db/queries/answers.sql @@ -0,0 +1,11 @@ +-- name: UpsertAnswer :exec +INSERT INTO answers (question_id, author_id, body, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (question_id) DO UPDATE +SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at; + +-- name: GetAnswer :one +SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at +FROM answers a +JOIN users u ON u.id = a.author_id +WHERE a.question_id = $1; diff --git a/db/queries/questions.sql b/db/queries/questions.sql new file mode 100644 index 0000000..a0a9029 --- /dev/null +++ b/db/queries/questions.sql @@ -0,0 +1,59 @@ +-- name: CreateQuestion :exec +INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) +VALUES ($1, $2, $3, $4, $5, $6, 0, $7); + +-- name: HideQuestion :exec +UPDATE questions +SET hidden = 1 +WHERE id = $1; + +-- name: ListHunt :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE(SUM(v.value), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN votes v ON v.question_id = q.id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0 +GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id +ORDER BY score DESC, q.created_at ASC; + +-- name: GetQuestion :one +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id + ), 0)::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.id = sqlc.arg(id); + +-- name: ListQuestionsByAuthor :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, + 0::bigint AS user_vote +FROM questions q +JOIN users u ON u.id = q.author_id +LEFT JOIN answers a ON a.question_id = q.id +WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0 +ORDER BY q.created_at DESC; + +-- name: ListQuestionsAnsweredBy :many +SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + 1::bigint AS answered, + 0::bigint AS user_vote +FROM answers ans +JOIN questions q ON q.id = ans.question_id +JOIN users u ON u.id = q.author_id +WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0 +ORDER BY ans.updated_at DESC; diff --git a/db/queries/users.sql b/db/queries/users.sql new file mode 100644 index 0000000..508a327 --- /dev/null +++ b/db/queries/users.sql @@ -0,0 +1,43 @@ +-- name: CreateUser :exec +INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) +VALUES ($1, $2, $3, $4, $5, '', '', $6); + +-- name: GetUserByID :one +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE id = $1; + +-- name: GetUserByUsername :one +SELECT id, username, name, role, avatar_url, state, created_at, password_hash +FROM users +WHERE username = $1; + +-- name: ListUsers :many +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +ORDER BY created_at ASC; + +-- name: CountAdmins :one +SELECT COUNT(*)::bigint AS count +FROM users +WHERE role = $1; + +-- name: GetUserRole :one +SELECT role +FROM users +WHERE id = $1; + +-- name: UpdateUserRole :execresult +UPDATE users +SET role = $1 +WHERE id = $2; + +-- name: UpdateUserState :exec +UPDATE users +SET state = $1 +WHERE id = $2; + +-- name: UpdateUserStateAndAvatar :exec +UPDATE users +SET state = $1, avatar_url = $2 +WHERE id = $3; diff --git a/db/queries/votes.sql b/db/queries/votes.sql new file mode 100644 index 0000000..7268fed --- /dev/null +++ b/db/queries/votes.sql @@ -0,0 +1,14 @@ +-- name: GetVote :one +SELECT value +FROM votes +WHERE user_id = $1 AND question_id = $2; + +-- name: DeleteVote :exec +DELETE FROM votes +WHERE user_id = $1 AND question_id = $2; + +-- name: UpsertVote :exec +INSERT INTO votes (user_id, question_id, value) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, question_id) DO UPDATE +SET value = excluded.value; diff --git a/internal/store/answer.go b/internal/store/answer.go index 571186c..9dcfe3f 100644 --- a/internal/store/answer.go +++ b/internal/store/answer.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "plumber/internal/store/sqlc" ) // Answer is an admin reply to a question. @@ -35,23 +37,27 @@ func (a *Answer) Upsert(ctx context.Context) error { a.CreatedAt = now } a.UpdatedAt = now - _, err := a.db.ExecContext(ctx, ` -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`, - a.QuestionID, a.AuthorID, a.Body, a.CreatedAt, a.UpdatedAt) - return err + 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) { - var a Answer - err := db.QueryRowContext(ctx, ` -SELECT a.question_id, a.author_id, u.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`, questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt) + r, err := sqlc.New(db).GetAnswer(ctx, questionID) if err != nil { return nil, err } - a.db = db - return &a, nil + return &Answer{ + QuestionID: r.QuestionID, + AuthorID: r.AuthorID, + AuthorName: r.AuthorName, + Body: r.Body, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + db: db, + }, nil } diff --git a/internal/store/generate.go b/internal/store/generate.go new file mode 100644 index 0000000..44730d8 --- /dev/null +++ b/internal/store/generate.go @@ -0,0 +1,3 @@ +package store + +//go:generate make -C ../.. sqlc diff --git a/internal/store/question.go b/internal/store/question.go index 81928a4..8915e8f 100644 --- a/internal/store/question.go +++ b/internal/store/question.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "plumber/internal/pacific" + "plumber/internal/store/sqlc" ) // RankedQuestion is a question row with score / vote annotations for lists. @@ -51,9 +52,15 @@ func (q *RankedQuestion) Create(ctx context.Context) error { if q.CreatedAt == "" { q.CreatedAt = time.Now().UTC().Format(time.RFC3339) } - _, err := q.db.ExecContext(ctx, `INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`, - q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt) - return err + 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. @@ -61,108 +68,82 @@ func (q *RankedQuestion) Hide(ctx context.Context) error { if q == nil || q.db == nil { return fmt.Errorf("question: no database") } - _, err := q.db.ExecContext(ctx, `UPDATE questions SET hidden = 1 WHERE id = $1`, q.ID) - if err == nil { - q.Hidden = true + 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, } - return err } func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) { - rows, err := db.QueryContext(ctx, ` -SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE(SUM(v.value), 0) AS score, - CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, - COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) 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`, viewerID, huntDate) + rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{ + UserID: viewerID, + HuntDate: huntDate, + }) if err != nil { return nil, err } - defer rows.Close() - return scanRankedList(db, rows) + 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) { - row := db.QueryRowContext(ctx, ` -SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, - CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, - COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) 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`, viewerID, id) - q, err := scanRanked(db, row) + r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{ + UserID: 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 := db.QueryContext(ctx, ` -SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, - CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered, - 0 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`, authorID) + rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, authorID) if err != nil { return nil, err } - defer rows.Close() - return scanRankedList(db, rows) + 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 := db.QueryContext(ctx, ` -SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score, - 1 AS answered, - 0 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`, adminID) + rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, adminID) if err != nil { return nil, err } - defer rows.Close() - return scanRankedList(db, rows) -} - -type scanned interface { - Scan(dest ...any) error -} - -func scanRanked(db *sql.DB, rows scanned) (RankedQuestion, error) { - var q RankedQuestion - var hidden, answered int - err := rows.Scan(&q.ID, &q.AuthorID, &q.AuthorName, &q.Title, &q.Body, &q.City, &q.HuntDate, &hidden, &q.CreatedAt, &q.Score, &answered, &q.UserVote) - q.Hidden = hidden != 0 - q.Answered = answered != 0 - q.db = db - return q, err -} - -func scanRankedList(db *sql.DB, rows *sql.Rows) ([]RankedQuestion, error) { - var out []RankedQuestion - for rows.Next() { - q, err := scanRanked(db, rows) - if err != nil { - return nil, err - } - out = append(out, q) + 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, rows.Err() + return out, nil } diff --git a/internal/store/sqlc/answers.sql.go b/internal/store/sqlc/answers.sql.go new file mode 100644 index 0000000..2cdf6dc --- /dev/null +++ b/internal/store/sqlc/answers.sql.go @@ -0,0 +1,66 @@ +// 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 +} diff --git a/internal/store/sqlc/db.go b/internal/store/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/store/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go new file mode 100644 index 0000000..93ac364 --- /dev/null +++ b/internal/store/sqlc/models.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +type Answer struct { + QuestionID string + AuthorID string + Body string + CreatedAt string + UpdatedAt string +} + +type Question struct { + ID string + AuthorID string + Title string + Body string + City string + HuntDate string + Hidden int32 + CreatedAt string +} + +type User struct { + ID string + Username string + Name string + PasswordHash string + Role string + AvatarUrl string + State string + CreatedAt string +} + +type Vote struct { + UserID string + QuestionID string + Value int32 +} diff --git a/internal/store/sqlc/questions.sql.go b/internal/store/sqlc/questions.sql.go new file mode 100644 index 0000000..dc8b4e1 --- /dev/null +++ b/internal/store/sqlc/questions.sql.go @@ -0,0 +1,296 @@ +// 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(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 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 { + UserID 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.UserID, 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 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 +` + +type ListHuntParams struct { + UserID string + HuntDate string +} + +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.UserID, arg.HuntDate) + 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(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 +` + +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, authorID string) ([]ListQuestionsAnsweredByRow, error) { + rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, authorID) + 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(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 +` + +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, authorID string) ([]ListQuestionsByAuthorRow, error) { + rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, authorID) + 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 +} diff --git a/internal/store/sqlc/users.sql.go b/internal/store/sqlc/users.sql.go new file mode 100644 index 0000000..8faed99 --- /dev/null +++ b/internal/store/sqlc/users.sql.go @@ -0,0 +1,222 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const countAdmins = `-- name: CountAdmins :one +SELECT COUNT(*)::bigint AS count +FROM users +WHERE role = $1 +` + +func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) { + row := q.db.QueryRowContext(ctx, countAdmins, role) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) +VALUES ($1, $2, $3, $4, $5, '', '', $6) +` + +type CreateUserParams struct { + ID string + Username string + Name string + PasswordHash string + Role string + CreatedAt string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Username, + arg.Name, + arg.PasswordHash, + arg.Role, + arg.CreatedAt, + ) + return err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +WHERE id = $1 +` + +type GetUserByIDRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string +} + +func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &i.CreatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, name, role, avatar_url, state, created_at, password_hash +FROM users +WHERE username = $1 +` + +type GetUserByUsernameRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string + PasswordHash string +} + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i GetUserByUsernameRow + err := row.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &i.CreatedAt, + &i.PasswordHash, + ) + return i, err +} + +const getUserRole = `-- name: GetUserRole :one +SELECT role +FROM users +WHERE id = $1 +` + +func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, getUserRole, id) + var role string + err := row.Scan(&role) + return role, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, username, name, role, avatar_url, state, created_at +FROM users +ORDER BY created_at ASC +` + +type ListUsersRow struct { + ID string + Username string + Name string + Role string + AvatarUrl string + State string + CreatedAt string +} + +func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { + rows, err := q.db.QueryContext(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUsersRow{} + for rows.Next() { + var i ListUsersRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Name, + &i.Role, + &i.AvatarUrl, + &i.State, + &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 updateUserRole = `-- name: UpdateUserRole :execresult +UPDATE users +SET role = $1 +WHERE id = $2 +` + +type UpdateUserRoleParams struct { + Role string + ID string +} + +func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) { + return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID) +} + +const updateUserState = `-- name: UpdateUserState :exec +UPDATE users +SET state = $1 +WHERE id = $2 +` + +type UpdateUserStateParams struct { + State string + ID string +} + +func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error { + _, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID) + return err +} + +const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec +UPDATE users +SET state = $1, avatar_url = $2 +WHERE id = $3 +` + +type UpdateUserStateAndAvatarParams struct { + State string + AvatarUrl string + ID string +} + +func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error { + _, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID) + return err +} diff --git a/internal/store/sqlc/votes.sql.go b/internal/store/sqlc/votes.sql.go new file mode 100644 index 0000000..da375d5 --- /dev/null +++ b/internal/store/sqlc/votes.sql.go @@ -0,0 +1,61 @@ +// 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 upsertVote = `-- name: UpsertVote :exec +INSERT INTO votes (user_id, question_id, value) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, question_id) DO UPDATE +SET value = excluded.value +` + +type UpsertVoteParams struct { + UserID string + QuestionID string + Value int32 +} + +func (q *Queries) UpsertVote(ctx context.Context, arg UpsertVoteParams) error { + _, err := q.db.ExecContext(ctx, upsertVote, arg.UserID, arg.QuestionID, arg.Value) + return err +} diff --git a/internal/store/user.go b/internal/store/user.go index 21e57a3..13bf795 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -9,6 +9,8 @@ import ( "time" "github.com/google/uuid" + + "plumber/internal/store/sqlc" ) // ErrLastAdmin is returned when demoting the only remaining admin. @@ -22,7 +24,7 @@ const ( RoleAdmin Role = "admin" ) -// User is an account row. Methods run SQL against db. +// User is an account row. Methods run SQL against db via sqlc. type User struct { ID string Username string @@ -48,6 +50,20 @@ func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) } +func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User { + return &User{ + ID: id, + Username: username, + Name: name, + Role: Role(role), + AvatarURL: avatarURL, + State: state, + CreatedAt: createdAt, + PasswordHash: passwordHash, + db: db, + } +} + // Create inserts the user. Sets ID, Name, and CreatedAt when empty. func (u *User) Create(ctx context.Context) error { if u == nil || u.db == nil { @@ -66,9 +82,14 @@ func (u *User) Create(ctx context.Context) error { if u.CreatedAt == "" { u.CreatedAt = time.Now().UTC().Format(time.RFC3339) } - _, err := u.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`, - u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt) - return err + return sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{ + ID: u.ID, + Username: u.Username, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: string(u.Role), + CreatedAt: u.CreatedAt, + }) } // SetRole updates this user's role (last-admin safe). @@ -85,21 +106,24 @@ func (u *User) SetRole(ctx context.Context, role Role) error { } defer tx.Rollback() - var current string - err = tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = $1`, u.ID).Scan(¤t) + q := sqlc.New(tx) + current, err := q.GetUserRole(ctx, u.ID) if err != nil { return err } if Role(current) == RoleAdmin && role == RoleUser { - var n int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil { + n, err := q.CountAdmins(ctx, string(RoleAdmin)) + if err != nil { return err } if n <= 1 { return ErrLastAdmin } } - res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(role), u.ID) + res, err := q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{ + Role: string(role), + ID: u.ID, + }) if err != nil { return err } @@ -123,61 +147,47 @@ func (u *User) SaveProfile(ctx context.Context) error { return fmt.Errorf("user: no database") } u.State = strings.TrimSpace(u.State) + q := sqlc.New(u.db) if u.AvatarURL == "" { - _, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1 WHERE id = $2`, u.State, u.ID) - return err + return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID}) } - _, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, u.State, u.AvatarURL, u.ID) - return err + return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{ + State: u.State, + AvatarUrl: u.AvatarURL, + ID: u.ID, + }) } func CountAdmins(ctx context.Context, db *sql.DB) (int, error) { - var n int - err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n) - return n, err + n, err := sqlc.New(db).CountAdmins(ctx, string(RoleAdmin)) + return int(n), err } func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) { - rows, err := db.QueryContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`) + rows, err := sqlc.New(db).ListUsers(ctx) if err != nil { return nil, err } - defer rows.Close() - var out []User - for rows.Next() { - var u User - var role string - if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil { - return nil, err - } - u.Role = Role(role) - u.db = db - out = append(out, u) + out := make([]User, 0, len(rows)) + for _, r := range rows { + u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "") + out = append(out, *u) } - return out, rows.Err() + return out, nil } func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) { - return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false) -} - -func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) { - return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true) -} - -func scanUser(db *sql.DB, row *sql.Row, withSecrets bool) (*User, error) { - var u User - var role string - var err error - if withSecrets { - err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash) - } else { - err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt) - } + r, err := sqlc.New(db).GetUserByID(ctx, id) if err != nil { return nil, err } - u.Role = Role(role) - u.db = db - return &u, nil + return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil +} + +func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) { + r, err := sqlc.New(db).GetUserByUsername(ctx, NormalizeUsername(username)) + if err != nil { + return nil, err + } + return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil } diff --git a/internal/store/vote.go b/internal/store/vote.go index d3c07b6..c28efad 100644 --- a/internal/store/vote.go +++ b/internal/store/vote.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "fmt" + + "plumber/internal/store/sqlc" ) // Vote toggles or sets a user's vote on a question (value must be 1 or -1). @@ -16,16 +18,20 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) return err } defer tx.Rollback() - var current sql.NullInt64 - err = tx.QueryRowContext(ctx, `SELECT value FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID).Scan(¤t) + + q := sqlc.New(tx) + current, err := q.GetVote(ctx, sqlc.GetVoteParams{UserID: userID, QuestionID: questionID}) if err != nil && err != sql.ErrNoRows { return err } - if err == nil && current.Valid && int(current.Int64) == value { - _, err = tx.ExecContext(ctx, `DELETE FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID) + if err == nil && int(current) == value { + err = q.DeleteVote(ctx, sqlc.DeleteVoteParams{UserID: userID, QuestionID: questionID}) } else { - _, err = tx.ExecContext(ctx, `INSERT INTO votes (user_id, question_id, value) VALUES ($1, $2, $3) -ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`, userID, questionID, value) + err = q.UpsertVote(ctx, sqlc.UpsertVoteParams{ + UserID: userID, + QuestionID: questionID, + Value: int32(value), + }) } if err != nil { return err diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..0f71770 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "schema.sql" + queries: "db/queries" + gen: + go: + package: "sqlc" + out: "internal/store/sqlc" + sql_package: "database/sql" + emit_json_tags: false + emit_empty_slices: true