quiz resetc

This commit is contained in:
Krispire Admin
2026-10-15 20:59:53 +05:30
parent 4b38047c26
commit b1f78b1655
8 changed files with 70 additions and 7 deletions
+12 -3
View File
@@ -170,11 +170,20 @@ func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request)
response.JSON(w, http.StatusOK, res)
}
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
// that person's attempt so the quiz is pending for them again.
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
writeQuizError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Users ─────────────────────────────────────────────────────────────────
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
// see that a quiz exists; signed-in ones also get their progress. Returns
// null when the page has no quiz.
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
// page with the caller's progress, or null when the page has none.
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
if err != nil {
+1
View File
@@ -59,6 +59,7 @@ var Catalog = []Permission{
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
{"quizzes:delete", "Quizzes", "Delete quizzes"},
{"quizzes:results", "Quizzes", "View participants and responses"},
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
{"short_urls:read", "Short URLs", "View short links and stats"},
}
+14
View File
@@ -341,6 +341,20 @@ func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.Qu
return out, nil
}
// ResetAttempt deletes one person's attempt — its answers go with it — so the
// quiz is pending for them again and they can take it afresh.
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
if err != nil {
return fmt.Errorf("reset attempt: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotStarted
}
return nil
}
// ── Taking a quiz ─────────────────────────────────────────────────────────
// PublishedForPath returns the newest live quiz on a content page, or nil.
+8 -4
View File
@@ -725,17 +725,20 @@ func (s *Server) setupRouter() {
// Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today)
// Quizzes. Browsing is open to signed-out readers (they see that a
// quiz exists); taking one, and seeing results, needs an account.
// Quizzes are for signed-in users: listing, finding one on a page,
// taking it and reading results all need an account. The one open
// endpoint is a quiz's own card — title, description, what it's
// attached to, never its questions — so link previews (OpenGraph)
// can name the quiz when it's shared.
r.Route("/quizzes", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/", s.quizH.List)
r.Get("/for", s.quizH.ForPath)
r.Get("/{id}", s.quizH.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.quizH.List)
r.Get("/for", s.quizH.ForPath)
r.Post("/{id}/start", s.quizH.Start)
r.Post("/{id}/answer", s.quizH.Answer)
r.Get("/{id}/result", s.quizH.Result)
@@ -1262,6 +1265,7 @@ func (s *Server) setupRouter() {
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
can("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
+10
View File
@@ -269,6 +269,16 @@ func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID stri
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.
+18
View File
@@ -278,6 +278,24 @@ func TestQuiz_TakingFlow(t *testing.T) {
t.Errorf("responses = %+v, %v", stats, err)
}
// Staff can reset one participant: their attempt and answers go, the quiz
// is pending for them again, and they can retake it from the start.
if err := svc.ResetParticipant(ctx, quiz.ID, other); !errors.Is(err, repository.ErrQuizNotStarted) {
t.Errorf("resetting someone with no attempt: %v, want ErrQuizNotStarted", err)
}
if err := svc.ResetParticipant(ctx, quiz.ID, user); err != nil {
t.Fatal(err)
}
if card, _ := svc.Card(ctx, user, quiz.ID); card.My.State != model.QuizNotStarted {
t.Errorf("state after reset = %s, want not_started", card.My.State)
}
if parts, _ := svc.Participants(ctx, quiz.ID); len(parts) != 0 {
t.Errorf("participants after reset = %d, want 0", len(parts))
}
if again, err := svc.Start(ctx, user, quiz.ID); err != nil || again.Answered != 0 || again.Question == nil {
t.Errorf("retaking after reset: %+v, %v", again, 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)
@@ -0,0 +1 @@
UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset');
@@ -0,0 +1,6 @@
-- quizzes:reset — delete one participant's attempt so the quiz is pending
-- for them again. Granted to the seeded Admin role (Superadmin has '*';
-- Moderator and custom roles get it only when a superadmin adds it).
UPDATE admin_roles
SET permissions = array_append(permissions, 'quizzes:reset')
WHERE key = 'admin' AND NOT ('quizzes:reset' = ANY(permissions));