feat: quiz and rbac
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user