734 lines
23 KiB
Go
734 lines
23 KiB
Go
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")
|
|
// ErrQuizTargetTaken: that chapter, lecture or conversation already has a
|
|
// quiz (enforced by the quizzes_one_per_target index).
|
|
ErrQuizTargetTaken = errors.New("only one quiz is allowed per link")
|
|
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 {
|
|
if isUniqueViolation(err, "quizzes_one_per_target") {
|
|
return "", ErrQuizTargetTaken
|
|
}
|
|
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 {
|
|
if isUniqueViolation(err, "quizzes_one_per_target") {
|
|
return ErrQuizTargetTaken
|
|
}
|
|
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
|
|
}
|
|
|
|
// QuizOnTarget returns the live quiz (published or draft) already attached to
|
|
// a content path, other than exceptID — or "" when the path is free.
|
|
func (r *QuizRepo) QuizOnTarget(ctx context.Context, path, exceptID string) (id, title string, err error) {
|
|
err = r.pg.Pool.QueryRow(ctx, `
|
|
SELECT id, title FROM quizzes
|
|
WHERE target_path = $1 AND deleted_at IS NULL
|
|
AND ($2 = '' OR id::text <> $2)
|
|
LIMIT 1`, path, exceptID).Scan(&id, &title)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", "", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("quiz on target: %w", err)
|
|
}
|
|
return id, title, 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
|
|
}
|
|
|
|
// ResetAttempt deletes one person's attempt — its answers go with it — so the
|
|
// quiz is pending for them again and they can take it afresh.
|
|
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
|
|
tag, err := r.pg.Pool.Exec(ctx,
|
|
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("reset attempt: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrQuizNotStarted
|
|
}
|
|
return 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
|
|
}
|