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 } // 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 }