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, err error) { p := NormalizeContentPath(raw) if p == "" { return model.QuizStandalone, 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, "", ErrQuizTarget } kind = model.QuizLecture if t.Type == "conversation" { kind = model.QuizConversation } title = t.Name 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, "", ErrQuizTarget } if o := strings.TrimSpace(override); o != "" { title = o } return kind, &p, title, 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, 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 } 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.quizzes.Update(ctx, id, in, !locked); err != nil { return nil, err } return s.GetAdmin(ctx, id) } 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) }