423 lines
13 KiB
Go
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)
|
|
}
|