feat: quiz and rbac
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
Reference in New Issue
Block a user