From b1f78b1655b64ee5114fc98da8f3834c8336a937 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 15 Oct 2026 20:59:53 +0530 Subject: [PATCH] quiz resetc --- internal/handler/quiz.go | 15 ++++++++++++--- internal/rbac/rbac.go | 1 + internal/repository/quiz_repo.go | 14 ++++++++++++++ internal/server/server.go | 12 ++++++++---- internal/service/quiz_service.go | 10 ++++++++++ internal/service/quiz_service_test.go | 18 ++++++++++++++++++ migrations/0059_quiz_reset_permission.down.sql | 1 + migrations/0059_quiz_reset_permission.up.sql | 6 ++++++ 8 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 migrations/0059_quiz_reset_permission.down.sql create mode 100644 migrations/0059_quiz_reset_permission.up.sql diff --git a/internal/handler/quiz.go b/internal/handler/quiz.go index 0403dfe..da42e28 100644 --- a/internal/handler/quiz.go +++ b/internal/handler/quiz.go @@ -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 { diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go index 7ea3c19..bb879a9 100644 --- a/internal/rbac/rbac.go +++ b/internal/rbac/rbac.go @@ -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"}, } diff --git a/internal/repository/quiz_repo.go b/internal/repository/quiz_repo.go index 7e6c3d4..9a940ef 100644 --- a/internal/repository/quiz_repo.go +++ b/internal/repository/quiz_repo.go @@ -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. diff --git a/internal/server/server.go b/internal/server/server.go index f4fb606..8ce1f85 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/service/quiz_service.go b/internal/service/quiz_service.go index e4421ee..655925a 100644 --- a/internal/service/quiz_service.go +++ b/internal/service/quiz_service.go @@ -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. diff --git a/internal/service/quiz_service_test.go b/internal/service/quiz_service_test.go index c359378..31667f2 100644 --- a/internal/service/quiz_service_test.go +++ b/internal/service/quiz_service_test.go @@ -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) diff --git a/migrations/0059_quiz_reset_permission.down.sql b/migrations/0059_quiz_reset_permission.down.sql new file mode 100644 index 0000000..1006a34 --- /dev/null +++ b/migrations/0059_quiz_reset_permission.down.sql @@ -0,0 +1 @@ +UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset'); diff --git a/migrations/0059_quiz_reset_permission.up.sql b/migrations/0059_quiz_reset_permission.up.sql new file mode 100644 index 0000000..fbe0a1e --- /dev/null +++ b/migrations/0059_quiz_reset_permission.up.sql @@ -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));