feat: quiz and rbac

This commit is contained in:
Krispire Admin
2026-10-15 19:43:17 +05:30
parent 7d7ba2a002
commit 4b38047c26
26 changed files with 3270 additions and 75 deletions
+386
View File
@@ -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)
}
+302
View File
@@ -0,0 +1,302 @@
package service
import (
"context"
"errors"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
func q(prompt string, correct int, opts ...string) repository.QuestionInput {
out := repository.QuestionInput{Prompt: prompt}
for i, o := range opts {
out.Options = append(out.Options, repository.OptionInput{Body: o, IsCorrect: i == correct})
}
return out
}
func withExplanation(x repository.QuestionInput, e string) repository.QuestionInput {
x.Explanation = e
return x
}
func newQuizSvc(t *testing.T) (*QuizService, *repository.Postgres) {
t.Helper()
_, _, pg := testChatService(t)
// Stand-alone quizzes never consult content, so no Mongo is needed.
return NewQuizService(repository.NewQuizRepo(pg), nil), pg
}
func cleanupQuiz(t *testing.T, pg *repository.Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM quizzes WHERE id = $1`, id)
})
}
func TestQuiz_Validation(t *testing.T) {
svc, pg := newQuizSvc(t)
ctx := context.Background()
admin := chatUser(t, pg, "Admin")
cases := map[string]struct {
d QuizDraft
want error
}{
"no title": {QuizDraft{Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTitle},
"no questions": {QuizDraft{Title: "T"}, ErrQuizNoQuestions},
"one option": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a")}}, ErrQuizOptionCount},
"six options": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a", "b", "c", "d", "e", "f")}}, ErrQuizOptionCount},
"no correct": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", -1, "a", "b")}}, ErrQuizOneCorrect},
"blank prompt": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q(" ", 0, "a", "b")}}, ErrQuizPrompt},
"bad target url": {QuizDraft{Title: "T", TargetURL: "/letters/x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTarget},
}
for name, c := range cases {
if _, err := svc.Create(ctx, admin, c.d); !errors.Is(err, c.want) {
t.Errorf("%s: err = %v, want %v", name, err, c.want)
}
}
two := q("p", 1, "a", "b")
two.Options[0].IsCorrect = true // two correct
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); !errors.Is(err, ErrQuizOneCorrect) {
t.Errorf("two correct: %v", err)
}
// …which is exactly what a multi question allows, but it needs one.
two.Multi = true
if quiz, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); err != nil {
t.Errorf("multi with two correct: %v", err)
} else {
cleanupQuiz(t, pg, quiz.ID)
}
none := q("p", -1, "a", "b")
none.Multi = true
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{none}}); !errors.Is(err, ErrQuizNoCorrect) {
t.Errorf("multi with none correct: %v, want ErrQuizNoCorrect", err)
}
}
// A multi question is scored correct only on the exact set of correct
// options: a subset or an extra wrong pick both miss.
func TestQuiz_MultiChoice(t *testing.T) {
svc, pg := newQuizSvc(t)
ctx := context.Background()
admin := chatUser(t, pg, "Admin")
multi := func(prompt string) repository.QuestionInput {
x := q(prompt, 0, "right A", "right B", "wrong")
x.Options[1].IsCorrect = true
x.Multi = true
return x
}
quiz, err := svc.Create(ctx, admin, QuizDraft{
Title: "Select all",
Questions: []repository.QuestionInput{multi("exact"), multi("subset"), multi("extra"), q("single", 0, "a", "b")},
})
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, quiz.ID)
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
t.Fatal(err)
}
full, _ := svc.GetAdmin(ctx, quiz.ID)
if !full.Questions[0].Multi || full.Questions[3].Multi {
t.Fatal("multi flag didn't round-trip")
}
opt := func(qi, oi int) string { return full.Questions[qi].Options[oi].ID }
user := chatUser(t, pg, "U")
play, err := svc.Start(ctx, user, quiz.ID)
if err != nil {
t.Fatal(err)
}
if !play.Question.Multi {
t.Error("the player isn't told the question takes several answers")
}
// Nothing chosen is refused.
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{}); !errors.Is(err, repository.ErrQuizChoiceCount) {
t.Errorf("empty answer: %v, want ErrQuizChoiceCount", err)
}
steps := [][]string{
{opt(0, 1), opt(0, 0), opt(0, 0)}, // exact set, any order, duplicate ignored
{opt(1, 0)}, // subset
{opt(2, 0), opt(2, 1), opt(2, 2)}, // every option, one wrong
}
for _, ids := range steps {
if play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, ids); err != nil {
t.Fatal(err)
}
}
// A single-answer question still takes exactly one.
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0), opt(3, 1)}); !errors.Is(err, repository.ErrQuizChoiceCount) {
t.Errorf("two picks on a single question: %v, want ErrQuizChoiceCount", err)
}
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0)}); err != nil {
t.Fatal(err)
}
res, err := svc.Result(ctx, user, quiz.ID)
if err != nil {
t.Fatal(err)
}
got := []bool{}
for _, it := range res.Items {
got = append(got, it.IsCorrect)
}
if res.Score != 2 || !got[0] || got[1] || got[2] || !got[3] {
t.Errorf("score %d, per question %v; want 2, [true false false true]", res.Score, got)
}
if n := len(res.Items[0].ChosenOptionIDs); n != 2 {
t.Errorf("exact answer stored %d options, want 2 (duplicate dropped)", n)
}
stats, err := svc.Responses(ctx, quiz.ID)
if err != nil {
t.Fatal(err)
}
// Every option of the "extra" question was picked once.
for _, o := range stats[2].Options {
if o.Picks != 1 {
t.Errorf("option %q picks = %d, want 1", o.Body, o.Picks)
}
}
}
func TestQuiz_TakingFlow(t *testing.T) {
svc, pg := newQuizSvc(t)
ctx := context.Background()
admin, user, other := chatUser(t, pg, "Admin"), chatUser(t, pg, "U"), chatUser(t, pg, "O")
quiz, err := svc.Create(ctx, admin, QuizDraft{
Title: "Bg basics",
Questions: []repository.QuestionInput{
withExplanation(q("Who spoke the *Gītā*?", 0, "Kṛṣṇa", "Arjuna", "Sañjaya"), "Kṛṣṇa, to Arjuna."),
q("Where?", 1, "Mathurā", "Kurukṣetra"),
},
})
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, quiz.ID)
// Drafts aren't reachable.
if _, err := svc.Start(ctx, user, quiz.ID); !errors.Is(err, ErrQuizUnpublished) {
t.Fatalf("starting a draft: %v", err)
}
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
t.Fatal(err)
}
play, err := svc.Start(ctx, user, quiz.ID)
if err != nil {
t.Fatal(err)
}
if play.Question == nil || play.Total != 2 || play.Answered != 0 {
t.Fatalf("first step = %+v", play)
}
// The correct answer never reaches someone mid-attempt — nor the
// explanation, which discusses it.
if play.Question.Explanation != "" {
t.Error("explanation leaked to a user mid-attempt")
}
for _, o := range play.Question.Options {
if o.IsCorrect != nil {
t.Fatal("is_correct leaked to a user mid-attempt")
}
}
// Nor can they read a result yet.
if _, err := svc.Result(ctx, user, quiz.ID); !errors.Is(err, repository.ErrQuizNotFinished) {
t.Errorf("result mid-attempt: %v, want ErrQuizNotFinished", err)
}
card, _ := svc.Card(ctx, user, quiz.ID)
if card.My.State != model.QuizInProgress {
t.Errorf("state after start = %s, want in_progress", card.My.State)
}
// Answering out of order (a question not yet served) is refused.
full, _ := svc.GetAdmin(ctx, quiz.ID)
second := full.Questions[1]
if _, err := svc.Answer(ctx, user, quiz.ID, second.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizNotCurrent) {
t.Errorf("answering ahead: %v, want ErrQuizNotCurrent", err)
}
// An option from another question is refused.
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizBadOption) {
t.Errorf("foreign option: %v, want ErrQuizBadOption", err)
}
// Q1 right, Q2 wrong.
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{full.Questions[0].Options[0].ID})
if err != nil {
t.Fatal(err)
}
// Can't change an answer.
if _, err := svc.Answer(ctx, user, quiz.ID, full.Questions[0].ID, []string{full.Questions[0].Options[1].ID}); !errors.Is(err, repository.ErrQuizAlreadyAnswer) {
t.Errorf("re-answering: %v, want ErrQuizAlreadyAnswer", err)
}
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID})
if err != nil {
t.Fatal(err)
}
if !play.Done {
t.Fatalf("after the last answer: %+v", play)
}
res, err := svc.Result(ctx, user, quiz.ID)
if err != nil {
t.Fatal(err)
}
if res.Score != 1 || res.Total != 2 || res.CompletedAt == nil {
t.Errorf("result = score %d/%d completed %v", res.Score, res.Total, res.CompletedAt)
}
for _, it := range res.Items {
if it.ResponseMs == nil {
t.Error("a response time wasn't recorded")
}
if len(it.ChosenOptionIDs) != 1 {
t.Error("the chosen option wasn't recorded")
}
}
if !res.Items[0].IsCorrect || res.Items[1].IsCorrect {
t.Error("per-question correctness is wrong")
}
// Only your own result: someone who never took it has none.
if _, err := svc.Result(ctx, other, quiz.ID); !errors.Is(err, repository.ErrQuizNotStarted) {
t.Errorf("another user's result: %v, want ErrQuizNotStarted", err)
}
// Admin sees the participant and the per-question picks.
parts, err := svc.Participants(ctx, quiz.ID)
if err != nil || len(parts) != 1 || parts[0].Score != 1 || parts[0].Answered != 2 {
t.Errorf("participants = %+v, %v", parts, err)
}
stats, err := svc.Responses(ctx, quiz.ID)
if err != nil || len(stats) != 2 || stats[0].Correct != 1 || stats[1].Correct != 0 {
t.Errorf("responses = %+v, %v", stats, err)
}
// Taken, so questions are frozen — but the title can still change.
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
}
if upd, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "Bhagavad-gītā basics"}); err != nil || len(upd.Questions) != 2 {
t.Errorf("retitling a taken quiz: %v (questions %d)", err, len(upd.Questions))
}
}
func TestNormalizeContentPath(t *testing.T) {
for in, want := range map[string]string{
"https://prabhupadasays.org/books/bg/2/?x=1#v3": "/books/bg/2",
"/transcriptions/720720sb-london": "/transcriptions/720720sb-london",
"books/bg/2": "/books/bg/2",
"": "",
"/": "",
} {
if got := NormalizeContentPath(in); got != want {
t.Errorf("NormalizeContentPath(%q) = %q, want %q", in, got, want)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"bytes"
"html/template"
"strings"
"go.uber.org/zap"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
// isn't configured (local development leaves it empty).
const defaultAdminURL = "https://admin.prabhupadasays.org"
// sendStaffWelcome tells someone they've been given admin access, and as
// what. Fire-and-forget, like the other notification emails: the grant is
// already committed, so a mail failure is logged, never surfaced.
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
if s.mail == nil {
return
}
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
s.logger.Info("staff granted; no email on file, skipping welcome",
zap.String("user_id", m.UserID))
return
}
var buf bytes.Buffer
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": strings.TrimSpace(m.Name),
"RoleName": m.RoleName,
"AdminURL": s.adminURL,
}); err != nil {
s.logger.Warn("render staff welcome email failed", zap.Error(err))
return
}
to := strings.TrimSpace(*m.Email)
go func() {
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("staff welcome email send failed",
zap.String("user_id", m.UserID), zap.Error(err))
}
}()
}
// staffWelcomeEmailTpl — same styling as the other account emails. The role
// name is admin-entered (custom roles) and auto-escaped by html/template.
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
<p>Log in with your email and password at
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
<p style="margin:1.25em 0">
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
</p>
<p>For any queries, contact your co-ordinator.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+256
View File
@@ -0,0 +1,256 @@
package service
import (
"context"
"errors"
"regexp"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
var (
ErrStaffSelf = errors.New("you can't change your own staff access")
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
ErrRoleBadPermission = errors.New("unknown permission")
ErrRoleName = errors.New("a role needs a name")
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
)
// StaffService manages who can use the admin panel and with what powers.
// Every method here is reachable only with rbac.ManageStaff — superadmins.
type StaffService struct {
staff *repository.StaffRepo
// mail tells newly added staff about their access; nil sends nothing.
mail mailer.Mailer
logger *zap.Logger
adminURL string
}
// NewStaffService wires staff management. adminURL is the panel's address for
// the welcome email; empty falls back to the production panel.
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
if adminURL == "" {
adminURL = defaultAdminURL
}
if logger == nil {
logger = zap.NewNop()
}
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
}
// Me describes the caller, for the panel to decide what to show.
type Me struct {
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
Permissions []string `json:"permissions"`
}
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
return s.staff.ListStaff(ctx)
}
// isSuperadminRole reports whether a role id is the seeded superadmin.
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
ro, err := s.staff.Role(ctx, roleID)
if err != nil {
return false, err
}
return ro.Key == repository.SuperadminKey, nil
}
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
// out of that state if they're the last one — demoting, revoking or blocking
// them would leave no one able to manage staff, with no way back from the UI.
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
return nil
}
n, err := s.staff.ActiveSuperadmins(ctx)
if err != nil {
return err
}
if n <= 1 {
return ErrLastSuperadmin
}
return nil
}
// Grant gives an account a role, by handle.
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
if err != nil {
return nil, err
}
if userID == actorID {
return nil, ErrStaffSelf
}
if _, err := s.staff.Role(ctx, roleID); err != nil {
return nil, err
}
// Granting to someone who is already staff is a role change, and must
// pass the same last-superadmin check.
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
}
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
return nil, err
}
m, err := s.staff.StaffMember(ctx, userID)
if err != nil {
return nil, err
}
// Only a NEW staff member is welcomed; role changes above stay quiet.
s.sendStaffWelcome(m)
return m, nil
}
// ChangeRole moves a staff member to another role.
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
toSuper, err := s.isSuperadminRole(ctx, roleID)
if err != nil {
return nil, err
}
if !toSuper {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// Revoke removes a staff member's access entirely.
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
if targetID == actorID {
return ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return err
}
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return err
}
return s.staff.SetRole(ctx, targetID, nil)
}
// SetBlocked suspends or restores a staff member's panel access, keeping their
// role so restoring them is one action rather than a re-grant.
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
if blocked {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
return s.staff.ListRoles(ctx)
}
// cleanPermissions validates a custom role's permissions against the catalog.
// The wildcard and admins:manage aren't in the catalog, so they are refused
// here — a custom role can never become a second superadmin.
func cleanPermissions(perms []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
if !rbac.Valid(p) {
return nil, ErrRoleBadPermission
}
seen[p] = true
out = append(out, p)
}
if len(out) == 0 {
return nil, ErrRoleNoPermissions
}
return out, nil
}
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
func roleKeyFor(name string) string {
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
if len(k) > 40 {
k = k[:40]
}
return k
}
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
name = strings.TrimSpace(name)
key := roleKeyFor(name)
if name == "" || key == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
}
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return nil, err
}
if ro.IsSystem {
return nil, ErrRoleSystem
}
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
return nil, err
}
return s.staff.Role(ctx, id)
}
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return err
}
if ro.IsSystem {
return ErrRoleSystem
}
return s.staff.DeleteRole(ctx, id)
}
+181
View File
@@ -0,0 +1,181 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
t.Helper()
var id string
if err := pg.Pool.QueryRow(context.Background(),
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
t.Fatalf("role %s: %v", key, err)
}
return id
}
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
t.Helper()
id := chatUser(t, pg, roleKey)
if _, err := pg.Pool.Exec(context.Background(),
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
t.Fatal(err)
}
return id
}
// The floor: removing the last active superadmin would leave nobody able to
// manage staff, with no way back from the UI.
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
staff := repository.NewStaffRepo(pg)
svc := NewStaffService(staff, nil, nil, "")
// Isolate from any staff already in the dev database: the floor counts
// superadmins globally. Parked in a REAL table — a TEMP table is
// per-connection, and the pool may run the cleanup on a different one,
// which would leave real staff stripped of their roles.
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
t.Fatal(err)
}
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
t.Errorf("restoring parked staff: %v", err)
return
}
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
})
a := makeStaff(t, pg, "superadmin")
b := makeStaff(t, pg, "superadmin")
mod := makeStaff(t, pg, "moderator")
// Nobody acts on themselves.
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
}
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
t.Errorf("self-block: %v, want ErrStaffSelf", err)
}
// A superadmin can block another admin/mod — and another superadmin while
// one remains.
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
t.Errorf("block moderator: %v", err)
}
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
t.Errorf("block second superadmin: %v", err)
}
// Now a is the only active one: a blocked b can't be used to demote a.
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
}
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
}
// A blocked staff member is turned away at the door.
acc, err := staff.Access(ctx, mod)
if err != nil || !acc.Blocked {
t.Errorf("blocked moderator access = %+v, %v", acc, err)
}
}
// A custom role is built only from the catalog — never the wildcard or
// admins:manage, which would make it a second superadmin.
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
}
}
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
set := rbac.NewSet(ro.Permissions)
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
t.Errorf("custom role permissions resolved wrong: %v", set.List())
}
// System roles are read-only.
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
}
}
// captureMailer records sends; the welcome email goes out on a goroutine.
type captureMailer struct{ sent chan [3]string }
func (m *captureMailer) Send(to, subject, body string) error {
m.sent <- [3]string{to, subject, body}
return nil
}
// Adding someone as staff emails them their role and where to log in; moving
// an existing staff member to another role does not.
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
mail := &captureMailer{sent: make(chan [3]string, 4)}
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
actor := chatUser(t, pg, "Actor")
target, handle := chatUserWithHandle(t, pg, "Target")
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
})
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
t.Fatal(err)
}
select {
case got := <-mail.sent:
if !strings.HasPrefix(got[0], "chatsvc-") {
t.Errorf("sent to %q, want the target's address", got[0])
}
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
if !strings.Contains(got[2], want) {
t.Errorf("email body missing %q", want)
}
}
if !strings.Contains(got[1], "Moderator") {
t.Errorf("subject %q doesn't name the role", got[1])
}
case <-time.After(3 * time.Second):
t.Fatal("no welcome email was sent")
}
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
t.Fatal(err)
}
select {
case got := <-mail.sent:
t.Errorf("a role change sent an email: %q", got[1])
case <-time.After(300 * time.Millisecond):
}
}