feat: quiz and rbac

This commit is contained in:
Krispire Admin
2026-10-15 19:43:17 +05:30
parent 7d7ba2a002
commit 4b38047c26
26 changed files with 3270 additions and 75 deletions
+2 -1
View File
@@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
u.disciple_id,
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
COUNT(*) OVER() AS total
FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers,
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
&u.DiscipleID, &total); err != nil {
&u.DiscipleID, &u.AdminRole, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err)
}
// Resolve plan default + overrides so the panel shows what's in force.
+693
View File
@@ -0,0 +1,693 @@
package repository
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrQuizNotFound = errors.New("quiz not found")
ErrQuizNotStarted = errors.New("start the quiz first")
ErrQuizNotCurrent = errors.New("that isn't the current question")
ErrQuizBadOption = errors.New("that option isn't part of this question")
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
)
type QuizRepo struct{ pg *Postgres }
func NewQuizRepo(pg *Postgres) *QuizRepo { return &QuizRepo{pg: pg} }
// QuizInput is a whole quiz as the admin editor submits it.
type QuizInput struct {
Title string
Description string
TargetKind string
TargetPath *string
TargetTitle string
Questions []QuestionInput
}
type QuestionInput struct {
Prompt string
Explanation string
Multi bool
Options []OptionInput
}
type OptionInput struct {
Body string
IsCorrect bool
}
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
q.target_title, q.published_at, q.created_at, q.updated_at,
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
&q.TargetTitle, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
return row.Scan(append(dest, extra...)...)
}
// ── Admin ─────────────────────────────────────────────────────────────────
func (r *QuizRepo) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`,
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id),
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id AND a.completed_at IS NOT NULL)
FROM quizzes q WHERE q.deleted_at IS NULL
ORDER BY q.updated_at DESC`)
if err != nil {
return nil, fmt.Errorf("list quizzes: %w", err)
}
defer rows.Close()
out := []model.Quiz{}
for rows.Next() {
var q model.Quiz
if err := scanQuiz(rows, &q, &q.AttemptCount, &q.CompletedCount); err != nil {
return nil, fmt.Errorf("scan quiz: %w", err)
}
out = append(out, q)
}
return out, rows.Err()
}
// Get loads a quiz with its questions. revealAnswers controls whether
// IsCorrect is filled in: true for the admin editor and results screens,
// false for anyone mid-attempt.
func (r *QuizRepo) Get(ctx context.Context, id string, revealAnswers bool) (*model.Quiz, error) {
var q model.Quiz
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
FROM quizzes q WHERE q.id = $1 AND q.deleted_at IS NULL`, id), &q)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrQuizNotFound
}
if err != nil {
return nil, fmt.Errorf("load quiz: %w", err)
}
if q.Questions, err = r.questions(ctx, id, revealAnswers); err != nil {
return nil, err
}
return &q, nil
}
func (r *QuizRepo) questions(ctx context.Context, quizID string, reveal bool) ([]model.QuizQuestion, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT qq.id, qq.ord, qq.prompt, qq.explanation, qq.multi,
o.id, o.ord, o.body, o.is_correct
FROM quiz_questions qq
JOIN quiz_options o ON o.question_id = qq.id
WHERE qq.quiz_id = $1
ORDER BY qq.ord, o.ord`, quizID)
if err != nil {
return nil, fmt.Errorf("load questions: %w", err)
}
defer rows.Close()
out := []model.QuizQuestion{}
for rows.Next() {
var (
qid, prompt, expl, oid, body string
qord, oord int
multi, correct bool
)
if err := rows.Scan(&qid, &qord, &prompt, &expl, &multi, &oid, &oord, &body, &correct); err != nil {
return nil, fmt.Errorf("scan question: %w", err)
}
if len(out) == 0 || out[len(out)-1].ID != qid {
out = append(out, model.QuizQuestion{ID: qid, Ord: qord, Prompt: prompt, Explanation: expl, Multi: multi})
}
opt := model.QuizOption{ID: oid, Ord: oord, Body: body}
if reveal {
c := correct
opt.IsCorrect = &c
}
last := &out[len(out)-1]
last.Options = append(last.Options, opt)
}
return out, rows.Err()
}
// Create stores a new quiz, unpublished, with its questions.
func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (string, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("begin quiz tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, createdBy,
).Scan(&id); err != nil {
return "", fmt.Errorf("create quiz: %w", err)
}
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
return "", err
}
return id, tx.Commit(ctx)
}
// Update rewrites a quiz. replaceQuestions is false once anyone has made an
// attempt: their answers point at specific questions and options, so those
// are frozen and only the title, description and target may change.
func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQuestions bool) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin quiz tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
tag, err := tx.Exec(ctx, `
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
target_path = $5, target_title = $6, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle)
if err != nil {
return fmt.Errorf("update quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
if replaceQuestions {
if _, err := tx.Exec(ctx, `DELETE FROM quiz_questions WHERE quiz_id = $1`, id); err != nil {
return fmt.Errorf("clear questions: %w", err)
}
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []QuestionInput) error {
for i, q := range qs {
var qid string
if err := tx.QueryRow(ctx, `
INSERT INTO quiz_questions (quiz_id, ord, prompt, explanation, multi)
VALUES ($1, $2, $3, $4, $5) RETURNING id`, quizID, i, q.Prompt, q.Explanation, q.Multi,
).Scan(&qid); err != nil {
return fmt.Errorf("insert question: %w", err)
}
for j, o := range q.Options {
if _, err := tx.Exec(ctx, `
INSERT INTO quiz_options (question_id, ord, body, is_correct)
VALUES ($1, $2, $3, $4)`, qid, j, o.Body, o.IsCorrect); err != nil {
return fmt.Errorf("insert option: %w", err)
}
}
}
return nil
}
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
return n, err
}
func (r *QuizRepo) SetPublished(ctx context.Context, id string, published bool) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE quizzes
SET published_at = CASE WHEN $2 THEN COALESCE(published_at, now()) ELSE NULL END,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`, id, published)
if err != nil {
return fmt.Errorf("publish quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
return nil
}
// Delete hides a quiz everywhere. Soft, so participants' results survive.
func (r *QuizRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE quizzes SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
if err != nil {
return fmt.Errorf("delete quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
return nil
}
// Participants lists everyone who started a quiz, best scores first.
func (r *QuizRepo) Participants(ctx context.Context, quizID string) ([]model.QuizParticipant, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url, a.started_at, a.completed_at,
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL),
a.score, a.total,
COALESCE((SELECT sum(x.response_ms) FROM quiz_answers x WHERE x.attempt_id = a.id), 0)
FROM quiz_attempts a
JOIN users u ON u.id = a.user_id
WHERE a.quiz_id = $1
ORDER BY (a.completed_at IS NOT NULL) DESC, a.score DESC, a.started_at`, quizID)
if err != nil {
return nil, fmt.Errorf("quiz participants: %w", err)
}
defer rows.Close()
out := []model.QuizParticipant{}
for rows.Next() {
var p model.QuizParticipant
if err := rows.Scan(&p.UserID, &p.Handle, &p.Name, &p.AvatarURL, &p.StartedAt,
&p.CompletedAt, &p.Answered, &p.Score, &p.Total, &p.TotalMs); err != nil {
return nil, fmt.Errorf("scan participant: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// QuestionStats aggregates every answer per question and option.
func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.QuizQuestionStat, error) {
qs, err := r.questions(ctx, quizID, true)
if err != nil {
return nil, err
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT x.question_id, COALESCE(x.option_ids::text[], '{}'), x.is_correct, x.response_ms
FROM quiz_answers x
JOIN quiz_questions qq ON qq.id = x.question_id
WHERE qq.quiz_id = $1 AND x.answered_at IS NOT NULL`, quizID)
if err != nil {
return nil, fmt.Errorf("quiz answers: %w", err)
}
defer rows.Close()
picks := map[string]int{}
type agg struct {
answered, correct int
times []int
}
per := map[string]*agg{}
for rows.Next() {
var qid string
var oids []string
var correct *bool
var ms *int
if err := rows.Scan(&qid, &oids, &correct, &ms); err != nil {
return nil, fmt.Errorf("scan answer: %w", err)
}
a := per[qid]
if a == nil {
a = &agg{}
per[qid] = a
}
a.answered++
if correct != nil && *correct {
a.correct++
}
if ms != nil {
a.times = append(a.times, *ms)
}
for _, oid := range oids {
picks[oid]++
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]model.QuizQuestionStat, 0, len(qs))
for _, q := range qs {
st := model.QuizQuestionStat{ID: q.ID, Ord: q.Ord, Prompt: q.Prompt, Multi: q.Multi}
if a := per[q.ID]; a != nil {
st.Answered, st.Correct = a.answered, a.correct
if n := len(a.times); n > 0 {
sum := 0
for _, t := range a.times {
sum += t
}
st.AvgMs = sum / n
sort.Ints(a.times)
st.MedianMs = a.times[n/2]
}
}
for _, o := range q.Options {
st.Options = append(st.Options, model.QuizOptionStat{QuizOption: o, Picks: picks[o.ID]})
}
out = append(out, st)
}
return out, nil
}
// ── Taking a quiz ─────────────────────────────────────────────────────────
// PublishedForPath returns the newest live quiz on a content page, or nil.
func (r *QuizRepo) PublishedForPath(ctx context.Context, path string) (*model.Quiz, error) {
var q model.Quiz
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
FROM quizzes q
WHERE q.target_path = $1 AND q.deleted_at IS NULL AND q.published_at IS NOT NULL
ORDER BY q.published_at DESC LIMIT 1`, path), &q)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("quiz for path: %w", err)
}
return &q, nil
}
// ListPublished returns every live quiz, newest first.
func (r *QuizRepo) ListPublished(ctx context.Context) ([]model.Quiz, error) {
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`
FROM quizzes q
WHERE q.deleted_at IS NULL AND q.published_at IS NOT NULL
ORDER BY q.published_at DESC`)
if err != nil {
return nil, fmt.Errorf("list published quizzes: %w", err)
}
defer rows.Close()
out := []model.Quiz{}
for rows.Next() {
var q model.Quiz
if err := scanQuiz(rows, &q); err != nil {
return nil, fmt.Errorf("scan quiz: %w", err)
}
out = append(out, q)
}
return out, rows.Err()
}
// Statuses returns the caller's progress on each of the given quizzes.
// Quizzes they haven't touched are simply absent.
func (r *QuizRepo) Statuses(ctx context.Context, userID string, quizIDs []string) (map[string]model.QuizStatus, error) {
out := map[string]model.QuizStatus{}
if userID == "" || len(quizIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT a.quiz_id, a.completed_at IS NOT NULL, a.score, a.total,
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL)
FROM quiz_attempts a
WHERE a.user_id = $1 AND a.quiz_id = ANY($2)`, userID, quizIDs)
if err != nil {
return nil, fmt.Errorf("quiz statuses: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
var done bool
var score, total, answered int
if err := rows.Scan(&id, &done, &score, &total, &answered); err != nil {
return nil, fmt.Errorf("scan status: %w", err)
}
st := model.QuizStatus{State: model.QuizInProgress, Answered: answered, Total: total}
if done {
st.State = model.QuizCompleted
s := score
st.Score = &s
}
out[id] = st
}
return out, rows.Err()
}
type attemptRow struct {
ID string
StartedAt time.Time
CompletedAt *time.Time
Score int
Total int
}
func (r *QuizRepo) attempt(ctx context.Context, quizID, userID string) (*attemptRow, error) {
var a attemptRow
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, started_at, completed_at, score, total
FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID,
).Scan(&a.ID, &a.StartedAt, &a.CompletedAt, &a.Score, &a.Total)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrQuizNotStarted
}
if err != nil {
return nil, fmt.Errorf("load attempt: %w", err)
}
return &a, nil
}
// Start opens the caller's attempt. Idempotent: starting again resumes.
func (r *QuizRepo) Start(ctx context.Context, quizID, userID string, total int) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO quiz_attempts (quiz_id, user_id, total) VALUES ($1, $2, $3)
ON CONFLICT (quiz_id, user_id) DO NOTHING`, quizID, userID, total)
if err != nil {
return fmt.Errorf("start attempt: %w", err)
}
return nil
}
// Next returns the first unanswered question and stamps the moment it was
// served. The stamp is set ONCE: fetching the same question again — a reload,
// a second tab — doesn't restart its clock.
func (r *QuizRepo) Next(ctx context.Context, quizID, userID string) (*model.QuizPlay, error) {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return nil, err
}
play := &model.QuizPlay{QuizID: quizID, Total: a.Total}
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND answered_at IS NOT NULL`,
a.ID).Scan(&play.Answered); err != nil {
return nil, fmt.Errorf("count answered: %w", err)
}
if a.CompletedAt != nil {
play.Done = true
return play, nil
}
var qid string
err = r.pg.Pool.QueryRow(ctx, `
SELECT qq.id FROM quiz_questions qq
WHERE qq.quiz_id = $1
AND NOT EXISTS (SELECT 1 FROM quiz_answers x
WHERE x.attempt_id = $2 AND x.question_id = qq.id
AND x.answered_at IS NOT NULL)
ORDER BY qq.ord LIMIT 1`, quizID, a.ID).Scan(&qid)
if errors.Is(err, pgx.ErrNoRows) {
// Every question answered but not yet marked complete — finish it.
if err := r.complete(ctx, a.ID); err != nil {
return nil, err
}
play.Done = true
return play, nil
}
if err != nil {
return nil, fmt.Errorf("next question: %w", err)
}
if _, err := r.pg.Pool.Exec(ctx, `
INSERT INTO quiz_answers (attempt_id, question_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, a.ID, qid); err != nil {
return nil, fmt.Errorf("stamp question: %w", err)
}
qs, err := r.questions(ctx, quizID, false)
if err != nil {
return nil, err
}
for i := range qs {
if qs[i].ID == qid {
play.Question = &qs[i]
// The explanation discusses the answer; it belongs on the
// results screen, not beside the question being answered.
play.Question.Explanation = ""
}
}
return play, nil
}
// Answer records the caller's choice on the CURRENT question: one option, or
// for a multi question every option they think applies.
//
// The question must have been served (Next stamped it), which enforces order
// and gives the server its own start time. Response time is shown→answered,
// both server clocks. An answer can't be changed once given. A multi answer
// is correct only when the chosen set equals the correct set exactly.
func (r *QuizRepo) Answer(ctx context.Context, quizID, userID, questionID string, optionIDs []string) error {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return err
}
if a.CompletedAt != nil {
return ErrQuizAlreadyAnswer
}
chosen := map[string]bool{}
for _, id := range optionIDs {
chosen[id] = true
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin answer tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
rows, err := tx.Query(ctx, `
SELECT qq.multi, o.id, o.is_correct FROM quiz_options o
JOIN quiz_questions qq ON qq.id = o.question_id
WHERE o.question_id = $1 AND qq.quiz_id = $2`, questionID, quizID)
if err != nil {
return fmt.Errorf("check options: %w", err)
}
var multi bool
known, wrongPick, missed := 0, false, false
for rows.Next() {
var oid string
var isCorrect bool
if err := rows.Scan(&multi, &oid, &isCorrect); err != nil {
rows.Close()
return fmt.Errorf("scan option: %w", err)
}
if chosen[oid] {
known++
wrongPick = wrongPick || !isCorrect
} else if isCorrect {
missed = true
}
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("check options: %w", err)
}
if known != len(chosen) {
// Something chosen isn't an option of this question (or the question
// isn't part of this quiz at all).
return ErrQuizBadOption
}
if len(chosen) == 0 || (!multi && len(chosen) != 1) {
return ErrQuizChoiceCount
}
correct := !wrongPick && !missed
ids := make([]string, 0, len(chosen))
for _, id := range optionIDs {
if chosen[id] {
ids = append(ids, id)
delete(chosen, id) // keep the first of any duplicate
}
}
var answeredAt *time.Time
err = tx.QueryRow(ctx, `
SELECT answered_at FROM quiz_answers
WHERE attempt_id = $1 AND question_id = $2 FOR UPDATE`, a.ID, questionID).Scan(&answeredAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuizNotCurrent
}
if err != nil {
return fmt.Errorf("load answer row: %w", err)
}
if answeredAt != nil {
return ErrQuizAlreadyAnswer
}
if _, err := tx.Exec(ctx, `
UPDATE quiz_answers
SET option_ids = $3::uuid[], answered_at = now(), is_correct = $4,
response_ms = GREATEST(0, (EXTRACT(EPOCH FROM (now() - shown_at)) * 1000)::int)
WHERE attempt_id = $1 AND question_id = $2`,
a.ID, questionID, ids, correct); err != nil {
return fmt.Errorf("record answer: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit answer: %w", err)
}
// Finish the attempt once nothing is left unanswered.
var remaining int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM quiz_questions qq
WHERE qq.quiz_id = $1 AND NOT EXISTS (
SELECT 1 FROM quiz_answers x WHERE x.attempt_id = $2
AND x.question_id = qq.id AND x.answered_at IS NOT NULL)`,
quizID, a.ID).Scan(&remaining); err != nil {
return fmt.Errorf("count remaining: %w", err)
}
if remaining == 0 {
return r.complete(ctx, a.ID)
}
return nil
}
func (r *QuizRepo) complete(ctx context.Context, attemptID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE quiz_attempts SET completed_at = now(),
score = (SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND is_correct)
WHERE id = $1 AND completed_at IS NULL`, attemptID)
if err != nil {
return fmt.Errorf("complete attempt: %w", err)
}
return nil
}
// Result returns an attempt with the answers revealed.
//
// allowIncomplete is false on the user's path, which refuses until the attempt
// is finished — otherwise a result would be a way to peek at the answers. The
// admin passes true to inspect an attempt still in progress.
func (r *QuizRepo) Result(ctx context.Context, quizID, userID string, allowIncomplete bool) (*model.QuizResult, error) {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return nil, err
}
if a.CompletedAt == nil && !allowIncomplete {
return nil, ErrQuizNotFinished
}
quiz, err := r.Get(ctx, quizID, true)
if err != nil {
return nil, err
}
res := &model.QuizResult{
Score: a.Score, Total: a.Total, StartedAt: a.StartedAt, CompletedAt: a.CompletedAt,
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT question_id, COALESCE(option_ids::text[], '{}'), COALESCE(is_correct, false), response_ms
FROM quiz_answers WHERE attempt_id = $1`, a.ID)
if err != nil {
return nil, fmt.Errorf("load result answers: %w", err)
}
defer rows.Close()
type ans struct {
options []string
correct bool
ms *int
}
got := map[string]ans{}
for rows.Next() {
var qid string
var x ans
if err := rows.Scan(&qid, &x.options, &x.correct, &x.ms); err != nil {
return nil, fmt.Errorf("scan result answer: %w", err)
}
got[qid] = x
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, q := range quiz.Questions {
item := model.QuizResultItem{QuizQuestion: q, ChosenOptionIDs: []string{}}
if x, ok := got[q.ID]; ok {
item.ChosenOptionIDs, item.IsCorrect, item.ResponseMs = x.options, x.correct, x.ms
if x.ms != nil {
res.TotalMs += *x.ms
}
}
res.Items = append(res.Items, item)
}
quiz.Questions = nil
res.Quiz = *quiz
return res, nil
}
+241
View File
@@ -0,0 +1,241 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrNotStaff = errors.New("not a staff member")
ErrRoleNotFound = errors.New("role not found")
ErrRoleKeyTaken = errors.New("a role with that name already exists")
ErrRoleInUse = errors.New("role is still assigned to staff")
)
// SuperadminKey is the seeded role that holds the wildcard.
const SuperadminKey = "superadmin"
type StaffRepo struct{ pg *Postgres }
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
// Access loads the caller's role and permissions. ErrNotStaff when the account
// holds no admin role at all.
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
var a model.StaffAccess
err := r.pg.Pool.QueryRow(ctx, `
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff access: %w", err)
}
return &a, nil
}
const staffSelect = `
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
ro.id, ro.key, ro.name, u.admin_blocked_at
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id`
func scanStaff(row pgx.Row, m *model.StaffMember) error {
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
}
// ListStaff returns every account holding a role, superadmins first.
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
if err != nil {
return nil, fmt.Errorf("list staff: %w", err)
}
defer rows.Close()
out := []model.StaffMember{}
for rows.Next() {
var m model.StaffMember
if err := scanStaff(rows, &m); err != nil {
return nil, fmt.Errorf("scan staff: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
var m model.StaffMember
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff member: %w", err)
}
return &m, nil
}
// SetRole grants or changes a role. A nil roleID revokes staff access and
// clears any block, so a later re-grant starts clean.
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_role_id = $2,
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
updated_at = now()
WHERE id = $1`, userID, roleID)
if err != nil {
return fmt.Errorf("set staff role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserGone
}
return nil
}
// SetBlocked suspends or restores a staff member's panel access.
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
if err != nil {
return fmt.Errorf("set staff blocked: %w", err)
}
return nil
}
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
// stops the last one from being demoted, revoked or blocked.
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
SuperadminKey).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count superadmins: %w", err)
}
return n, nil
}
// UserIDByHandle resolves a handle for granting a role.
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx,
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserGone
}
if err != nil {
return "", fmt.Errorf("resolve handle: %w", err)
}
return id, nil
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
ro.permissions, ro.created_at,
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
FROM admin_roles ro
ORDER BY ro.is_system DESC,
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
ro.name`)
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
defer rows.Close()
out := []model.AdminRole{}
for rows.Next() {
var ro model.AdminRole
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
return nil, fmt.Errorf("scan role: %w", err)
}
out = append(out, ro)
}
return out, rows.Err()
}
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, description, is_system, permissions, created_at
FROM admin_roles WHERE id = $1`, id,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRoleNotFound
}
if err != nil {
return nil, fmt.Errorf("load role: %w", err)
}
return &ro, nil
}
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO admin_roles (key, name, description, permissions)
VALUES ($1, $2, $3, $4)
RETURNING id, key, name, description, is_system, permissions, created_at`,
key, name, description, perms,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if err != nil {
if isUniqueViolation(err, "admin_roles_key_key") {
return nil, ErrRoleKeyTaken
}
return nil, fmt.Errorf("create role: %w", err)
}
return &ro, nil
}
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
// bug higher up still can't rewrite superadmin.
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE admin_roles
SET name = $2, description = $3, permissions = $4, updated_at = now()
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
// DeleteRole removes an unused CUSTOM role.
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
var members int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
return fmt.Errorf("count role members: %w", err)
}
if members > 0 {
return ErrRoleInUse
}
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
if err != nil {
return fmt.Errorf("delete role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
+2 -1
View File
@@ -39,7 +39,8 @@ type CategoryCount struct {
// the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{}
// Non-nil so an empty table serialises as [] rather than null.
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct {