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
+26 -3
View File
@@ -8,6 +8,8 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
@@ -15,9 +17,15 @@ import (
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
// the router, so every method here can assume an admin caller.
type AdminHandler struct{ svc *service.AdminService }
type AdminHandler struct {
svc *service.AdminService
// staff guards actions that would otherwise reach other staff members.
staff *repository.StaffRepo
}
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
return &AdminHandler{svc: svc, staff: staff}
}
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
func pageParams(r *http.Request) (q string, limit, offset int) {
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
}
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
target := chi.URLParam(r, "id")
// A banned account loses panel access, so banning a staff member is a way
// to remove them — which only superadmins may do. Without this, an Admin
// holding users:ban could ban a Superadmin out of the panel.
if _, err := h.staff.Access(r.Context(), target); err == nil {
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
response.Error(w, http.StatusForbidden,
"staff accounts are managed from the Staff page by a superadmin")
return
}
if target == middleware.UserID(r.Context()) {
response.Error(w, http.StatusConflict, "you can't ban yourself")
return
}
}
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update account")
return
+250
View File
@@ -0,0 +1,250 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// QuizHandler serves quiz authoring (admin) and quiz taking (users).
type QuizHandler struct {
svc *service.QuizService
}
func NewQuizHandler(svc *service.QuizService) *QuizHandler { return &QuizHandler{svc: svc} }
func writeQuizError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrQuizNotFound), errors.Is(err, service.ErrQuizUnpublished):
response.Error(w, http.StatusNotFound, "quiz not found")
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
errors.Is(err, service.ErrQuizLocked):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
errors.Is(err, service.ErrQuizNoCorrect),
errors.Is(err, service.ErrQuizTitle), errors.Is(err, service.ErrQuizNoQuestions),
errors.Is(err, service.ErrQuizTooMany), errors.Is(err, service.ErrQuizPrompt),
errors.Is(err, service.ErrQuizOptionCount), errors.Is(err, service.ErrQuizOptionBody),
errors.Is(err, service.ErrQuizOneCorrect), errors.Is(err, service.ErrQuizTarget):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "quiz request failed")
}
}
// ── Admin ─────────────────────────────────────────────────────────────────
type quizOptionReq struct {
Body string `json:"body"`
IsCorrect bool `json:"is_correct"`
}
type quizQuestionReq struct {
Prompt string `json:"prompt"`
Explanation string `json:"explanation"`
Multi bool `json:"multi"`
Options []quizOptionReq `json:"options"`
}
type quizReq struct {
Title string `json:"title"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
TargetTitle string `json:"target_title"`
Questions []quizQuestionReq `json:"questions"`
}
func (q quizReq) draft() service.QuizDraft {
d := service.QuizDraft{
Title: q.Title, Description: q.Description,
TargetURL: q.TargetURL, TargetTitle: q.TargetTitle,
}
for _, qq := range q.Questions {
in := repository.QuestionInput{Prompt: qq.Prompt, Explanation: qq.Explanation, Multi: qq.Multi}
for _, o := range qq.Options {
in.Options = append(in.Options, repository.OptionInput{Body: o.Body, IsCorrect: o.IsCorrect})
}
d.Questions = append(d.Questions, in)
}
return d
}
func (h *QuizHandler) AdminList(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListAdmin(r.Context())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) AdminGet(w http.ResponseWriter, r *http.Request) {
q, err := h.svc.GetAdmin(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) Create(w http.ResponseWriter, r *http.Request) {
var req quizReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
q, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), req.draft())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusCreated, q)
}
func (h *QuizHandler) Update(w http.ResponseWriter, r *http.Request) {
var req quizReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
q, err := h.svc.Update(r.Context(), chi.URLParam(r, "id"), req.draft())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) setPublished(w http.ResponseWriter, r *http.Request, on bool) {
q, err := h.svc.SetPublished(r.Context(), chi.URLParam(r, "id"), on)
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) Publish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
func (h *QuizHandler) Unpublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, false) }
func (h *QuizHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeQuizError(w, err)
return
}
response.NoContent(w)
}
func (h *QuizHandler) Participants(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Participants(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) Responses(w http.ResponseWriter, r *http.Request) {
stats, err := h.svc.Responses(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, stats)
}
func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request) {
res, err := h.svc.ParticipantResult(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
// ── 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.
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 {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, card)
}
func (h *QuizHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListPublished(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) Get(w http.ResponseWriter, r *http.Request) {
card, err := h.svc.Card(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, card)
}
func (h *QuizHandler) Start(w http.ResponseWriter, r *http.Request) {
play, err := h.svc.Start(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, play)
}
func (h *QuizHandler) Answer(w http.ResponseWriter, r *http.Request) {
var req struct {
QuestionID string `json:"question_id"`
OptionIDs []string `json:"option_ids"`
// Single-option form, still accepted from older clients.
OptionID string `json:"option_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.QuestionID == "" {
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
return
}
if len(req.OptionIDs) == 0 && req.OptionID != "" {
req.OptionIDs = []string{req.OptionID}
}
if len(req.OptionIDs) == 0 {
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
return
}
play, err := h.svc.Answer(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.QuestionID, req.OptionIDs)
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, play)
}
// Result — GET /quizzes/{id}/result. The caller's own; the user id is taken
// from the session, so there is no way to ask for anyone else's.
func (h *QuizHandler) Result(w http.ResponseWriter, r *http.Request) {
res, err := h.svc.Result(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
type StaffHandler struct {
svc *service.StaffService
}
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
func writeStaffError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
errors.Is(err, repository.ErrUserGone):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
errors.Is(err, repository.ErrRoleKeyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
errors.Is(err, service.ErrRoleNoPermissions):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "staff request failed")
}
}
// Me — GET /admin/me. The caller's role and effective permissions, which the
// panel uses to hide what they can't do. Hiding is a courtesy: every route
// enforces its own permission regardless.
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
s := middleware.Staff(r.Context())
response.JSON(w, http.StatusOK, service.Me{
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
})
}
// Catalog — GET /admin/permissions. What a custom role may be built from.
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
response.JSON(w, http.StatusOK, rbac.Catalog)
}
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.List(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type grantReq struct {
Handle string `json:"handle"`
RoleID string `json:"role_id"`
}
// Grant — POST /admin/staff
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
var req grantReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
return
}
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// ChangeRole — PATCH /admin/staff/{id}
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
var req struct {
RoleID string `json:"role_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "role_id is required")
return
}
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Revoke — DELETE /admin/staff/{id}
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
roles, err := h.svc.Roles(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, roles)
}
type adminRoleReq struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusCreated, ro)
}
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, ro)
}
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}