Files
spsays-bff/internal/service/quiz_service.go
T
2026-10-18 13:02:13 +05:30

423 lines
13 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
const (
maxQuizQuestions = 100
minQuizOptions = 2
maxQuizOptions = 5
maxQuizTitle = 200
)
var (
ErrQuizTitle = errors.New("a quiz needs a title")
ErrQuizNoQuestions = errors.New("a quiz needs at least one question")
ErrQuizTooMany = fmt.Errorf("a quiz can have at most %d questions", maxQuizQuestions)
ErrQuizPrompt = errors.New("every question needs a prompt")
ErrQuizOptionCount = fmt.Errorf("each question needs %d to %d options", minQuizOptions, maxQuizOptions)
ErrQuizOptionBody = errors.New("every option needs text")
ErrQuizOneCorrect = errors.New("a single-answer question needs exactly one correct option")
ErrQuizNoCorrect = errors.New("a multiple-answer question needs at least one correct option")
ErrQuizLocked = errors.New("people have already taken this quiz, so its questions can't change")
ErrQuizTarget = errors.New("that link isn't a book chapter, lecture or conversation")
ErrQuizUnpublished = errors.New("this quiz isn't available")
)
// QuizService is both sides of quizzes: authoring for staff, taking for users.
type QuizService struct {
quizzes *repository.QuizRepo
// content resolves a pasted link into a target title and kind.
content *repository.VaniRepo
}
func NewQuizService(quizzes *repository.QuizRepo, content *repository.VaniRepo) *QuizService {
return &QuizService{quizzes: quizzes, content: content}
}
// QuizDraft is what the admin editor submits. TargetURL is pasted from the
// app; empty means a stand-alone quiz.
type QuizDraft struct {
Title string
Description string
TargetURL string
// TargetTitle overrides the resolved content title when set.
TargetTitle string
Questions []repository.QuestionInput
}
// NormalizeContentPath reduces a pasted link to the path a content page
// reports for itself: no scheme, host, query, fragment or trailing slash.
// Both sides normalise identically, so "https://…/books/bg/2/?x" and the
// page's own "/books/bg/2" meet.
func NormalizeContentPath(raw string) string {
p := strings.TrimSpace(raw)
if u, err := url.Parse(p); err == nil && u.Host != "" {
p = u.Path
}
p = strings.SplitN(strings.SplitN(p, "?", 2)[0], "#", 2)[0]
p = "/" + strings.Trim(p, "/")
if p == "/" {
return ""
}
return p
}
// resolveTarget works out what a link points at.
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, year *int, err error) {
p := NormalizeContentPath(raw)
if p == "" {
return model.QuizStandalone, nil, "", nil, nil
}
parts := strings.Split(strings.Trim(p, "/"), "/")
switch {
case len(parts) == 2 && parts[0] == "transcriptions":
t, err := s.content.TranscriptBySlug(ctx, parts[1])
if err != nil {
return "", nil, "", nil, ErrQuizTarget
}
kind = model.QuizLecture
if t.Type == "conversation" {
kind = model.QuizConversation
}
title = t.Name
year = t.Year
case len(parts) >= 3 && parts[0] == "books":
kind = model.QuizChapter
book := parts[1]
bookTitle := s.content.BookTitle(ctx, book)
if bookTitle == "" {
bookTitle = strings.ToUpper(book)
}
last := parts[len(parts)-1]
if _, numErr := strconv.Atoi(last); numErr == nil {
// /books/bg/2, /books/sb/1/2, /books/cc/adi/3 — a numbered chapter.
title = bookTitle + " — Chapter " + last
} else if pr, err := s.content.ProseBySlug(ctx, book, last); err == nil {
title = pr.Title
} else {
title = bookTitle
}
default:
return "", nil, "", nil, ErrQuizTarget
}
if o := strings.TrimSpace(override); o != "" {
title = o
}
return kind, &p, title, year, nil
}
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
if len(qs) == 0 {
return nil, ErrQuizNoQuestions
}
if len(qs) > maxQuizQuestions {
return nil, ErrQuizTooMany
}
out := make([]repository.QuestionInput, 0, len(qs))
for _, q := range qs {
q.Prompt = strings.TrimSpace(q.Prompt)
q.Explanation = strings.TrimSpace(q.Explanation)
if q.Prompt == "" {
return nil, ErrQuizPrompt
}
if len(q.Options) < minQuizOptions || len(q.Options) > maxQuizOptions {
return nil, ErrQuizOptionCount
}
correct := 0
for i := range q.Options {
q.Options[i].Body = strings.TrimSpace(q.Options[i].Body)
if q.Options[i].Body == "" {
return nil, ErrQuizOptionBody
}
if q.Options[i].IsCorrect {
correct++
}
}
if q.Multi && correct == 0 {
return nil, ErrQuizNoCorrect
}
if !q.Multi && correct != 1 {
return nil, ErrQuizOneCorrect
}
out = append(out, q)
}
return out, nil
}
func (s *QuizService) toInput(ctx context.Context, d QuizDraft, withQuestions bool) (repository.QuizInput, error) {
in := repository.QuizInput{
Title: strings.TrimSpace(d.Title),
Description: strings.TrimSpace(d.Description),
}
if in.Title == "" || len([]rune(in.Title)) > maxQuizTitle {
return in, ErrQuizTitle
}
var err error
if in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
return in, err
}
if withQuestions {
if in.Questions, err = validateQuestions(d.Questions); err != nil {
return in, err
}
}
return in, nil
}
// ── Admin ─────────────────────────────────────────────────────────────────
func (s *QuizService) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
return s.quizzes.ListAdmin(ctx)
}
func (s *QuizService) GetAdmin(ctx context.Context, id string) (*model.Quiz, error) {
q, err := s.quizzes.Get(ctx, id, true)
if err != nil {
return nil, err
}
if q.AttemptCount, err = s.quizzes.AttemptCount(ctx, id); err != nil {
return nil, err
}
return q, nil
}
func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (*model.Quiz, error) {
in, err := s.toInput(ctx, d, true)
if err != nil {
return nil, err
}
if err := s.ensureTargetFree(ctx, in, ""); err != nil {
return nil, err
}
id, err := s.quizzes.Create(ctx, in, actorID)
if err != nil {
return nil, err
}
return s.GetAdmin(ctx, id)
}
// Update rewrites a quiz. Once anyone has attempted it, its questions are
// frozen — their answers reference those exact questions and options — and
// only the title, description and target may change. Sending questions then
// is refused rather than silently ignored.
func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*model.Quiz, error) {
attempts, err := s.quizzes.AttemptCount(ctx, id)
if err != nil {
return nil, err
}
locked := attempts > 0
if locked && len(d.Questions) > 0 {
return nil, ErrQuizLocked
}
in, err := s.toInput(ctx, d, !locked)
if err != nil {
return nil, err
}
if err := s.ensureTargetFree(ctx, in, id); err != nil {
return nil, err
}
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
return nil, err
}
return s.GetAdmin(ctx, id)
}
// ensureTargetFree refuses a second quiz on the same chapter, lecture or
// conversation, naming the one already there so the admin can edit it
// instead. Stand-alone quizzes are never limited. The unique index backs
// this up against a race between two saves.
func (s *QuizService) ensureTargetFree(ctx context.Context, in repository.QuizInput, selfID string) error {
if in.TargetPath == nil {
return nil
}
_, title, err := s.quizzes.QuizOnTarget(ctx, *in.TargetPath, selfID)
if err != nil {
return err
}
if title != "" {
return fmt.Errorf("%w — %q is already linked to %s. Edit that quiz, or delete it first",
repository.ErrQuizTargetTaken, title, *in.TargetPath)
}
return nil
}
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
if published {
q, err := s.quizzes.Get(ctx, id, false)
if err != nil {
return nil, err
}
if len(q.Questions) == 0 {
return nil, ErrQuizNoQuestions
}
}
if err := s.quizzes.SetPublished(ctx, id, published); err != nil {
return nil, err
}
return s.GetAdmin(ctx, id)
}
func (s *QuizService) Delete(ctx context.Context, id string) error {
return s.quizzes.Delete(ctx, id)
}
func (s *QuizService) Participants(ctx context.Context, id string) ([]model.QuizParticipant, error) {
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
return nil, err
}
return s.quizzes.Participants(ctx, id)
}
func (s *QuizService) Responses(ctx context.Context, id string) ([]model.QuizQuestionStat, error) {
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
return nil, err
}
return s.quizzes.QuestionStats(ctx, id)
}
// ParticipantResult is one person's answers, for the admin — including an
// attempt still in progress, which a user can never see of their own.
func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID string) (*model.QuizResult, error) {
return s.quizzes.Result(ctx, quizID, userID, true)
}
// ResetParticipant wipes one participant's attempt; the quiz becomes pending
// for them. Works on unpublished quizzes too — staff may tidy results up
// while a quiz is offline.
func (s *QuizService) ResetParticipant(ctx context.Context, quizID, userID string) error {
if _, err := s.quizzes.Get(ctx, quizID, false); err != nil {
return err
}
return s.quizzes.ResetAttempt(ctx, quizID, userID)
}
// ── Taking ────────────────────────────────────────────────────────────────
// published loads a quiz that users may see.
func (s *QuizService) published(ctx context.Context, id string) (*model.Quiz, error) {
q, err := s.quizzes.Get(ctx, id, false)
if err != nil {
return nil, err
}
if q.PublishedAt == nil {
return nil, ErrQuizUnpublished
}
return q, nil
}
func (s *QuizService) withStatus(ctx context.Context, userID string, qs []model.Quiz) ([]model.QuizCard, error) {
ids := make([]string, len(qs))
for i, q := range qs {
ids[i] = q.ID
}
st, err := s.quizzes.Statuses(ctx, userID, ids)
if err != nil {
return nil, err
}
out := make([]model.QuizCard, len(qs))
for i, q := range qs {
q.Questions = nil
my, ok := st[q.ID]
if !ok {
my = model.QuizStatus{State: model.QuizNotStarted, Total: q.QuestionCount}
}
out[i] = model.QuizCard{Quiz: q, My: my}
}
return out, nil
}
// ForPath returns the quiz on a content page with the caller's progress, or
// nil when the page has none. userID may be empty for signed-out readers.
func (s *QuizService) ForPath(ctx context.Context, userID, rawPath string) (*model.QuizCard, error) {
p := NormalizeContentPath(rawPath)
if p == "" {
return nil, nil
}
q, err := s.quizzes.PublishedForPath(ctx, p)
if err != nil || q == nil {
return nil, err
}
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
if err != nil {
return nil, err
}
return &cards[0], nil
}
func (s *QuizService) ListPublished(ctx context.Context, userID string) ([]model.QuizCard, error) {
qs, err := s.quizzes.ListPublished(ctx)
if err != nil {
return nil, err
}
return s.withStatus(ctx, userID, qs)
}
func (s *QuizService) Card(ctx context.Context, userID, id string) (*model.QuizCard, error) {
q, err := s.published(ctx, id)
if err != nil {
return nil, err
}
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
if err != nil {
return nil, err
}
return &cards[0], nil
}
// Start opens (or resumes) the caller's attempt and returns where they are.
func (s *QuizService) Start(ctx context.Context, userID, id string) (*model.QuizPlay, error) {
q, err := s.published(ctx, id)
if err != nil {
return nil, err
}
if err := s.quizzes.Start(ctx, id, userID, len(q.Questions)); err != nil {
return nil, err
}
return s.next(ctx, userID, q)
}
func (s *QuizService) next(ctx context.Context, userID string, q *model.Quiz) (*model.QuizPlay, error) {
play, err := s.quizzes.Next(ctx, q.ID, userID)
if err != nil {
return nil, err
}
play.Title = q.Title
return play, nil
}
// Answer records a choice — one option, or several on a multi question — and
// returns the next step.
func (s *QuizService) Answer(ctx context.Context, userID, id, questionID string, optionIDs []string) (*model.QuizPlay, error) {
q, err := s.published(ctx, id)
if err != nil {
return nil, err
}
if err := s.quizzes.Answer(ctx, id, userID, questionID, optionIDs); err != nil {
return nil, err
}
return s.next(ctx, userID, q)
}
// Result is the caller's OWN finished attempt — the only result a user can
// read. The user id comes from the session, never the request.
func (s *QuizService) Result(ctx context.Context, userID, id string) (*model.QuizResult, error) {
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
return nil, err
}
return s.quizzes.Result(ctx, id, userID, false)
}