From 4b38047c26746aaaddf468f985cb2e419d22cc3a Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 15 Oct 2026 19:43:17 +0530 Subject: [PATCH] feat: quiz and rbac --- .env.example | 2 + internal/handler/admin.go | 29 +- internal/handler/quiz.go | 250 ++++++++ internal/handler/staff.go | 172 +++++ internal/middleware/admin.go | 61 +- internal/middleware/admin_test.go | 47 ++ internal/model/admin.go | 36 +- internal/model/quiz.go | 135 ++++ internal/model/staff.go | 37 ++ internal/rbac/rbac.go | 108 ++++ internal/repository/admin_repo.go | 3 +- internal/repository/quiz_repo.go | 693 +++++++++++++++++++++ internal/repository/staff_repo.go | 241 +++++++ internal/repository/stats_repo.go | 3 +- internal/server/server.go | 159 +++-- internal/service/quiz_service.go | 386 ++++++++++++ internal/service/quiz_service_test.go | 302 +++++++++ internal/service/staff_email.go | 65 ++ internal/service/staff_service.go | 256 ++++++++ internal/service/staff_service_test.go | 181 ++++++ migrations/0056_admin_rbac.down.sql | 7 + migrations/0056_admin_rbac.up.sql | 60 ++ migrations/0057_quizzes.down.sql | 5 + migrations/0057_quizzes.up.sql | 88 +++ migrations/0058_quiz_multi_choice.down.sql | 5 + migrations/0058_quiz_multi_choice.up.sql | 14 + 26 files changed, 3270 insertions(+), 75 deletions(-) create mode 100644 internal/handler/quiz.go create mode 100644 internal/handler/staff.go create mode 100644 internal/middleware/admin_test.go create mode 100644 internal/model/quiz.go create mode 100644 internal/model/staff.go create mode 100644 internal/rbac/rbac.go create mode 100644 internal/repository/quiz_repo.go create mode 100644 internal/repository/staff_repo.go create mode 100644 internal/service/quiz_service.go create mode 100644 internal/service/quiz_service_test.go create mode 100644 internal/service/staff_email.go create mode 100644 internal/service/staff_service.go create mode 100644 internal/service/staff_service_test.go create mode 100644 migrations/0056_admin_rbac.down.sql create mode 100644 migrations/0056_admin_rbac.up.sql create mode 100644 migrations/0057_quizzes.down.sql create mode 100644 migrations/0057_quizzes.up.sql create mode 100644 migrations/0058_quiz_multi_choice.down.sql create mode 100644 migrations/0058_quiz_multi_choice.up.sql diff --git a/.env.example b/.env.example index 4495b4c..045ff71 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,8 @@ COOKIE_SECURE=false # Frontend (CORS + email link base) FRONTEND_URL=http://localhost:3000 +# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development. +ADMIN_URL= GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= diff --git a/internal/handler/admin.go b/internal/handler/admin.go index a94fd5e..e17dd3f 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -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 diff --git a/internal/handler/quiz.go b/internal/handler/quiz.go new file mode 100644 index 0000000..0403dfe --- /dev/null +++ b/internal/handler/quiz.go @@ -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) +} diff --git a/internal/handler/staff.go b/internal/handler/staff.go new file mode 100644 index 0000000..56c7e74 --- /dev/null +++ b/internal/handler/staff.go @@ -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) +} diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go index e73bf4d..1b559b3 100644 --- a/internal/middleware/admin.go +++ b/internal/middleware/admin.go @@ -1,17 +1,38 @@ package middleware import ( + "context" + "errors" "net/http" + "github.com/prabhupadasays/spsays-bff/internal/rbac" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/response" ) -// RequireAdmin gates a route to users whose role is "admin". It MUST run after -// RequireAuth (it reads the authenticated user id from context, then looks up -// the row to check the role). Non-admins get 403; this keeps the whole -// /api/v1/admin/* surface invisible to ordinary accounts. -func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler { +type staffCtxKey struct{} + +// StaffContext is the caller's resolved admin access, set by RequireStaff. +type StaffContext struct { + RoleKey string + RoleName string + Perms rbac.Set +} + +// Staff returns the caller's staff context, or nil outside RequireStaff. +func Staff(ctx context.Context) *StaffContext { + s, _ := ctx.Value(staffCtxKey{}).(*StaffContext) + return s +} + +// RequireStaff admits accounts that hold an admin role and aren't blocked from +// the panel. It MUST run after RequireAuth. +// +// It resolves the role's permissions once per request and puts them in +// context, so the RequirePermission checks on each route are map lookups +// rather than further queries. Being staff gets you through the door only; +// every route then names the permission it needs. +func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uid := UserID(r.Context()) @@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler { response.Error(w, http.StatusUnauthorized, "not authenticated") return } - u, err := users.FindByID(r.Context(), uid) + access, err := staff.Access(r.Context(), uid) + if errors.Is(err, repository.ErrNotStaff) { + response.Error(w, http.StatusForbidden, "admin access required") + return + } if err != nil { response.Error(w, http.StatusInternalServerError, "auth check failed") return } - if u.Role != "admin" { - response.Error(w, http.StatusForbidden, "admin access required") + if access.Blocked { + response.Error(w, http.StatusForbidden, "your admin access has been suspended") + return + } + ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{ + RoleKey: access.RoleKey, + RoleName: access.RoleName, + Perms: rbac.NewSet(access.Permissions), + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequirePermission gates a route to staff holding perm. Must run inside +// RequireStaff; outside it there is no staff context and everything is refused. +func RequirePermission(perm string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s := Staff(r.Context()) + if s == nil || !s.Perms.Has(perm) { + response.Error(w, http.StatusForbidden, "you don't have permission to do that") return } next.ServeHTTP(w, r) diff --git a/internal/middleware/admin_test.go b/internal/middleware/admin_test.go new file mode 100644 index 0000000..6ef7b12 --- /dev/null +++ b/internal/middleware/admin_test.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/prabhupadasays/spsays-bff/internal/rbac" +) + +func withStaff(perms ...string) context.Context { + return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{ + RoleKey: "x", Perms: rbac.NewSet(perms), + }) +} + +func TestRequirePermission(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + gate := RequirePermission("users:ban")(ok) + + cases := []struct { + name string + ctx context.Context + want int + }{ + {"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden}, + {"admin with it", withStaff("users:ban"), http.StatusNoContent}, + {"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent}, + // Outside RequireStaff there's no staff context at all: refuse. + {"no staff context", context.Background(), http.StatusForbidden}, + } + for _, c := range cases { + rec := httptest.NewRecorder() + gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx)) + if rec.Code != c.want { + t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want) + } + } + + // Staff management is never reachable through a catalog permission. + if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) { + t.Error("a non-wildcard set holds admins:manage") + } +} + +func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) } diff --git a/internal/model/admin.go b/internal/model/admin.go index d444e0d..7b1596e 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -17,8 +17,10 @@ type AdminUser struct { Plan string `json:"plan"` EmailVerifiedAt *time.Time `json:"email_verified_at"` BannedAt *time.Time `json:"banned_at"` - PostCount int `json:"post_count"` - CreatedAt time.Time `json:"created_at"` + // AdminRole is the staff role key, nil for ordinary accounts. + AdminRole *string `json:"admin_role,omitempty"` + PostCount int `json:"post_count"` + CreatedAt time.Time `json:"created_at"` // Connected auth methods. HasPassword means email+password login is set up; // Providers lists linked OAuth providers (e.g. "google", "facebook"). @@ -56,18 +58,18 @@ type AdminDisciple struct { // AdminPost is a row in the admin posts table. type AdminPost struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Caption string `json:"caption"` - SourceTitle string `json:"source_title"` + ID string `json:"id"` + UserID string `json:"user_id"` + Caption string `json:"caption"` + SourceTitle string `json:"source_title"` SourceURL string `json:"source_url"` IsHidden bool `json:"is_hidden"` DeletedAt *time.Time `json:"deleted_at"` CreatedAt time.Time `json:"created_at"` - AuthorName string `json:"author_name"` - AuthorHandle string `json:"author_handle"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` + AuthorName string `json:"author_name"` + AuthorHandle string `json:"author_handle"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` } // AdminCollection is a row in the admin collections table. @@ -85,18 +87,18 @@ type AdminCollection struct { // AdminFeedback is a row in the admin feedback table (includes the reply). type AdminFeedback struct { - ID string `json:"id"` - Category string `json:"category"` - Message string `json:"message"` + ID string `json:"id"` + Category string `json:"category"` + Message string `json:"message"` ContactEmail *string `json:"contact_email"` CreatedAt time.Time `json:"created_at"` AcknowledgedAt *time.Time `json:"acknowledged_at"` ResolvedAt *time.Time `json:"resolved_at"` RepliedAt *time.Time `json:"replied_at"` - ReplyMessage *string `json:"reply_message"` - AuthorHandle *string `json:"author_handle"` - AuthorName *string `json:"author_name"` - AuthorEmail *string `json:"author_email"` + ReplyMessage *string `json:"reply_message"` + AuthorHandle *string `json:"author_handle"` + AuthorName *string `json:"author_name"` + AuthorEmail *string `json:"author_email"` } // AdminReport is a unified projection over the three report tables diff --git a/internal/model/quiz.go b/internal/model/quiz.go new file mode 100644 index 0000000..b97835d --- /dev/null +++ b/internal/model/quiz.go @@ -0,0 +1,135 @@ +package model + +import "time" + +// Quiz target kinds. +const ( + QuizChapter = "chapter" + QuizLecture = "lecture" + QuizConversation = "conversation" + QuizStandalone = "standalone" +) + +// Attempt states, as seen by the person taking the quiz. +const ( + QuizNotStarted = "not_started" + QuizInProgress = "in_progress" + QuizCompleted = "completed" +) + +// Quiz is a set of multiple-choice questions, optionally tied to a page. +type Quiz struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TargetKind string `json:"target_kind"` + TargetPath *string `json:"target_path,omitempty"` + TargetTitle string `json:"target_title"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + QuestionCount int `json:"question_count"` + // Admin-only counters. + AttemptCount int `json:"attempt_count,omitempty"` + CompletedCount int `json:"completed_count,omitempty"` + + Questions []QuizQuestion `json:"questions,omitempty"` +} + +// QuizQuestion is one question. Prompt and option bodies carry the same +// formatting markers as the rest of the admin text. +type QuizQuestion struct { + ID string `json:"id"` + Ord int `json:"ord"` + Prompt string `json:"prompt"` + Explanation string `json:"explanation,omitempty"` + // Multi questions ask for every correct option ("select all that apply"). + Multi bool `json:"multi"` + Options []QuizOption `json:"options"` +} + +// QuizOption is one choice. IsCorrect is nil whenever the reader must not +// know it yet — every response to someone mid-attempt. +type QuizOption struct { + ID string `json:"id"` + Ord int `json:"ord"` + Body string `json:"body"` + IsCorrect *bool `json:"is_correct,omitempty"` +} + +// QuizStatus is the caller's progress on a quiz. +type QuizStatus struct { + State string `json:"state"` + Answered int `json:"answered"` + Total int `json:"total"` + Score *int `json:"score,omitempty"` +} + +// QuizCard is a quiz plus the caller's progress, for buttons and lists. +type QuizCard struct { + Quiz + My QuizStatus `json:"my"` +} + +// QuizPlay is the next step of an attempt: the current question, or Done. +type QuizPlay struct { + QuizID string `json:"quiz_id"` + Title string `json:"title"` + Answered int `json:"answered"` + Total int `json:"total"` + Done bool `json:"done"` + Question *QuizQuestion `json:"question,omitempty"` +} + +// QuizResultItem is one question on a results screen. +type QuizResultItem struct { + QuizQuestion + ChosenOptionIDs []string `json:"chosen_option_ids"` + IsCorrect bool `json:"is_correct"` + ResponseMs *int `json:"response_ms,omitempty"` +} + +// QuizResult is a finished attempt. +type QuizResult struct { + Quiz Quiz `json:"quiz"` + Score int `json:"score"` + Total int `json:"total"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + TotalMs int `json:"total_ms"` + Items []QuizResultItem `json:"items"` +} + +// QuizParticipant is one person's attempt, for the admin participants list. +type QuizParticipant struct { + UserID string `json:"user_id"` + Handle string `json:"handle"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Answered int `json:"answered"` + Score int `json:"score"` + Total int `json:"total"` + TotalMs int `json:"total_ms"` +} + +// QuizOptionStat is how often one option was chosen. +type QuizOptionStat struct { + QuizOption + Picks int `json:"picks"` +} + +// QuizQuestionStat aggregates every response to one question. +type QuizQuestionStat struct { + ID string `json:"id"` + Ord int `json:"ord"` + Prompt string `json:"prompt"` + Multi bool `json:"multi"` + Answered int `json:"answered"` + Correct int `json:"correct"` + AvgMs int `json:"avg_ms"` + MedianMs int `json:"median_ms"` + Options []QuizOptionStat `json:"options"` +} diff --git a/internal/model/staff.go b/internal/model/staff.go new file mode 100644 index 0000000..c8f4a44 --- /dev/null +++ b/internal/model/staff.go @@ -0,0 +1,37 @@ +package model + +import "time" + +// AdminRole is a named set of admin-panel permissions. +type AdminRole struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + Permissions []string `json:"permissions"` + // MemberCount is how many staff hold the role, for the roles screen. + MemberCount int `json:"member_count"` + CreatedAt time.Time `json:"created_at"` +} + +// StaffMember is one account with admin-panel access. +type StaffMember struct { + UserID string `json:"user_id"` + Handle string `json:"handle"` + Name string `json:"name"` + Email *string `json:"email,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + RoleID string `json:"role_id"` + RoleKey string `json:"role_key"` + RoleName string `json:"role_name"` + BlockedAt *time.Time `json:"blocked_at,omitempty"` +} + +// StaffAccess is what the admin middleware needs about the caller. +type StaffAccess struct { + RoleKey string + RoleName string + Permissions []string + Blocked bool +} diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 0000000..7ea3c19 --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,108 @@ +// Package rbac is the admin panel's permission catalog. +// +// Permissions are "resource:action" strings. The catalog lives in code, not in +// the database, so a permission can't exist without a route enforcing it, and +// adding one is a reviewed change. Roles (in admin_roles) are just named sets +// of these strings. +package rbac + +import "slices" + +// Wildcard grants everything. Only the seeded superadmin role carries it. +const Wildcard = "*" + +// ManageStaff is held by superadmins alone: granting, changing, revoking and +// blocking other staff. It is deliberately absent from Catalog, so no custom +// role can ever be given it. +const ManageStaff = "admins:manage" + +// Permission is one grantable capability. +type Permission struct { + Key string `json:"key"` + Group string `json:"group"` + Label string `json:"label"` +} + +// Catalog is every permission a custom role may hold, grouped for the editor. +var Catalog = []Permission{ + {"stats:read", "Overview", "View dashboard stats"}, + + {"users:read", "Users", "View users"}, + {"users:notify", "Users", "Send users a message"}, + {"users:ban", "Users", "Ban and unban users"}, + {"users:limits", "Users", "Change bookmark and collection limits"}, + {"disciples:manage", "Users", "Link and unlink direct disciples"}, + + {"posts:moderate", "Moderation", "Hide, delete and restore posts"}, + {"comments:moderate", "Moderation", "Delete and restore comments"}, + {"collections:read", "Moderation", "View collections"}, + {"reports:read", "Moderation", "View reports"}, + {"reports:resolve", "Moderation", "Resolve reports"}, + + {"feedback:read", "Feedback", "View feedback"}, + {"feedback:reply", "Feedback", "Reply to feedback"}, + {"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"}, + + {"vani:read", "Vāṇī of the Day", "View the schedule"}, + {"vani:write", "Vāṇī of the Day", "Pin and clear days"}, + + {"rewards:read", "Rewards", "View the reward catalog"}, + {"rewards:write", "Rewards", "Add custom gifts"}, + {"rewards:send", "Rewards", "Send gifts to everyone"}, + {"rewards:delete", "Rewards", "Delete custom gifts"}, + + {"broadcasts:read", "Announcements", "View announcements"}, + {"broadcasts:write", "Announcements", "Create and delete drafts"}, + {"broadcasts:publish", "Announcements", "Publish to everyone"}, + + {"quizzes:read", "Quizzes", "View quizzes"}, + {"quizzes:write", "Quizzes", "Create, edit and publish quizzes"}, + {"quizzes:delete", "Quizzes", "Delete quizzes"}, + {"quizzes:results", "Quizzes", "View participants and responses"}, + + {"short_urls:read", "Short URLs", "View short links and stats"}, +} + +// Valid reports whether p is a grantable permission. +func Valid(p string) bool { + return slices.ContainsFunc(Catalog, func(c Permission) bool { return c.Key == p }) +} + +// Set is a resolved permission set for one staff member. +type Set struct { + all bool + keys map[string]bool +} + +// NewSet resolves a role's stored permissions. +func NewSet(perms []string) Set { + s := Set{keys: map[string]bool{}} + for _, p := range perms { + if p == Wildcard { + s.all = true + } + s.keys[p] = true + } + return s +} + +// Has reports whether the set grants p. +func (s Set) Has(p string) bool { return s.all || s.keys[p] } + +// List is the effective permissions, for the admin UI to decide what to show. +// A wildcard expands to the full catalog plus ManageStaff. +func (s Set) List() []string { + if s.all { + out := make([]string, 0, len(Catalog)+1) + for _, c := range Catalog { + out = append(out, c.Key) + } + return append(out, ManageStaff) + } + out := make([]string, 0, len(s.keys)) + for k := range s.keys { + out = append(out, k) + } + slices.Sort(out) + return out +} diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 937f1af..258d6d1 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers, u.bookmark_limit, u.public_collection_limit, u.private_collection_limit, u.disciple_id, + (SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role, COUNT(*) OVER() AS total FROM users u WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%' @@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &u.HasPassword, &u.Providers, &bookmarkLimit, &publicCollLimit, &privateCollLimit, - &u.DiscipleID, &total); err != nil { + &u.DiscipleID, &u.AdminRole, &total); err != nil { return nil, 0, fmt.Errorf("scan admin user: %w", err) } // Resolve plan default + overrides so the panel shows what's in force. diff --git a/internal/repository/quiz_repo.go b/internal/repository/quiz_repo.go new file mode 100644 index 0000000..7e6c3d4 --- /dev/null +++ b/internal/repository/quiz_repo.go @@ -0,0 +1,693 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +var ( + ErrQuizNotFound = errors.New("quiz not found") + ErrQuizNotStarted = errors.New("start the quiz first") + ErrQuizNotCurrent = errors.New("that isn't the current question") + ErrQuizBadOption = errors.New("that option isn't part of this question") + ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply") + ErrQuizAlreadyAnswer = errors.New("that question is already answered") + ErrQuizNotFinished = errors.New("the quiz isn't finished yet") +) + +type QuizRepo struct{ pg *Postgres } + +func NewQuizRepo(pg *Postgres) *QuizRepo { return &QuizRepo{pg: pg} } + +// QuizInput is a whole quiz as the admin editor submits it. +type QuizInput struct { + Title string + Description string + TargetKind string + TargetPath *string + TargetTitle string + Questions []QuestionInput +} + +type QuestionInput struct { + Prompt string + Explanation string + Multi bool + Options []OptionInput +} + +type OptionInput struct { + Body string + IsCorrect bool +} + +const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path, + q.target_title, q.published_at, q.created_at, q.updated_at, + (SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)` + +func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error { + dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath, + &q.TargetTitle, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount} + return row.Scan(append(dest, extra...)...) +} + +// ── Admin ───────────────────────────────────────────────────────────────── + +func (r *QuizRepo) ListAdmin(ctx context.Context) ([]model.Quiz, error) { + rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`, + (SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id), + (SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id AND a.completed_at IS NOT NULL) + FROM quizzes q WHERE q.deleted_at IS NULL + ORDER BY q.updated_at DESC`) + if err != nil { + return nil, fmt.Errorf("list quizzes: %w", err) + } + defer rows.Close() + out := []model.Quiz{} + for rows.Next() { + var q model.Quiz + if err := scanQuiz(rows, &q, &q.AttemptCount, &q.CompletedCount); err != nil { + return nil, fmt.Errorf("scan quiz: %w", err) + } + out = append(out, q) + } + return out, rows.Err() +} + +// Get loads a quiz with its questions. revealAnswers controls whether +// IsCorrect is filled in: true for the admin editor and results screens, +// false for anyone mid-attempt. +func (r *QuizRepo) Get(ctx context.Context, id string, revealAnswers bool) (*model.Quiz, error) { + var q model.Quiz + err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+` + FROM quizzes q WHERE q.id = $1 AND q.deleted_at IS NULL`, id), &q) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrQuizNotFound + } + if err != nil { + return nil, fmt.Errorf("load quiz: %w", err) + } + if q.Questions, err = r.questions(ctx, id, revealAnswers); err != nil { + return nil, err + } + return &q, nil +} + +func (r *QuizRepo) questions(ctx context.Context, quizID string, reveal bool) ([]model.QuizQuestion, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT qq.id, qq.ord, qq.prompt, qq.explanation, qq.multi, + o.id, o.ord, o.body, o.is_correct + FROM quiz_questions qq + JOIN quiz_options o ON o.question_id = qq.id + WHERE qq.quiz_id = $1 + ORDER BY qq.ord, o.ord`, quizID) + if err != nil { + return nil, fmt.Errorf("load questions: %w", err) + } + defer rows.Close() + out := []model.QuizQuestion{} + for rows.Next() { + var ( + qid, prompt, expl, oid, body string + qord, oord int + multi, correct bool + ) + if err := rows.Scan(&qid, &qord, &prompt, &expl, &multi, &oid, &oord, &body, &correct); err != nil { + return nil, fmt.Errorf("scan question: %w", err) + } + if len(out) == 0 || out[len(out)-1].ID != qid { + out = append(out, model.QuizQuestion{ID: qid, Ord: qord, Prompt: prompt, Explanation: expl, Multi: multi}) + } + opt := model.QuizOption{ID: oid, Ord: oord, Body: body} + if reveal { + c := correct + opt.IsCorrect = &c + } + last := &out[len(out)-1] + last.Options = append(last.Options, opt) + } + return out, rows.Err() +} + +// Create stores a new quiz, unpublished, with its questions. +func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (string, error) { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return "", fmt.Errorf("begin quiz tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + var id string + if err := tx.QueryRow(ctx, ` + INSERT INTO quizzes (title, description, target_kind, target_path, target_title, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, createdBy, + ).Scan(&id); err != nil { + return "", fmt.Errorf("create quiz: %w", err) + } + if err := insertQuestions(ctx, tx, id, in.Questions); err != nil { + return "", err + } + return id, tx.Commit(ctx) +} + +// Update rewrites a quiz. replaceQuestions is false once anyone has made an +// attempt: their answers point at specific questions and options, so those +// are frozen and only the title, description and target may change. +func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQuestions bool) error { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin quiz tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + tag, err := tx.Exec(ctx, ` + UPDATE quizzes SET title = $2, description = $3, target_kind = $4, + target_path = $5, target_title = $6, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle) + if err != nil { + return fmt.Errorf("update quiz: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrQuizNotFound + } + if replaceQuestions { + if _, err := tx.Exec(ctx, `DELETE FROM quiz_questions WHERE quiz_id = $1`, id); err != nil { + return fmt.Errorf("clear questions: %w", err) + } + if err := insertQuestions(ctx, tx, id, in.Questions); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []QuestionInput) error { + for i, q := range qs { + var qid string + if err := tx.QueryRow(ctx, ` + INSERT INTO quiz_questions (quiz_id, ord, prompt, explanation, multi) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, quizID, i, q.Prompt, q.Explanation, q.Multi, + ).Scan(&qid); err != nil { + return fmt.Errorf("insert question: %w", err) + } + for j, o := range q.Options { + if _, err := tx.Exec(ctx, ` + INSERT INTO quiz_options (question_id, ord, body, is_correct) + VALUES ($1, $2, $3, $4)`, qid, j, o.Body, o.IsCorrect); err != nil { + return fmt.Errorf("insert option: %w", err) + } + } + } + return nil +} + +func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) { + var n int + err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n) + return n, err +} + +func (r *QuizRepo) SetPublished(ctx context.Context, id string, published bool) error { + tag, err := r.pg.Pool.Exec(ctx, ` + UPDATE quizzes + SET published_at = CASE WHEN $2 THEN COALESCE(published_at, now()) ELSE NULL END, + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, id, published) + if err != nil { + return fmt.Errorf("publish quiz: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrQuizNotFound + } + return nil +} + +// Delete hides a quiz everywhere. Soft, so participants' results survive. +func (r *QuizRepo) Delete(ctx context.Context, id string) error { + tag, err := r.pg.Pool.Exec(ctx, + `UPDATE quizzes SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id) + if err != nil { + return fmt.Errorf("delete quiz: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrQuizNotFound + } + return nil +} + +// Participants lists everyone who started a quiz, best scores first. +func (r *QuizRepo) Participants(ctx context.Context, quizID string) ([]model.QuizParticipant, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT u.id, u.handle, u.name, u.avatar_url, a.started_at, a.completed_at, + (SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL), + a.score, a.total, + COALESCE((SELECT sum(x.response_ms) FROM quiz_answers x WHERE x.attempt_id = a.id), 0) + FROM quiz_attempts a + JOIN users u ON u.id = a.user_id + WHERE a.quiz_id = $1 + ORDER BY (a.completed_at IS NOT NULL) DESC, a.score DESC, a.started_at`, quizID) + if err != nil { + return nil, fmt.Errorf("quiz participants: %w", err) + } + defer rows.Close() + out := []model.QuizParticipant{} + for rows.Next() { + var p model.QuizParticipant + if err := rows.Scan(&p.UserID, &p.Handle, &p.Name, &p.AvatarURL, &p.StartedAt, + &p.CompletedAt, &p.Answered, &p.Score, &p.Total, &p.TotalMs); err != nil { + return nil, fmt.Errorf("scan participant: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// QuestionStats aggregates every answer per question and option. +func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.QuizQuestionStat, error) { + qs, err := r.questions(ctx, quizID, true) + if err != nil { + return nil, err + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT x.question_id, COALESCE(x.option_ids::text[], '{}'), x.is_correct, x.response_ms + FROM quiz_answers x + JOIN quiz_questions qq ON qq.id = x.question_id + WHERE qq.quiz_id = $1 AND x.answered_at IS NOT NULL`, quizID) + if err != nil { + return nil, fmt.Errorf("quiz answers: %w", err) + } + defer rows.Close() + + picks := map[string]int{} + type agg struct { + answered, correct int + times []int + } + per := map[string]*agg{} + for rows.Next() { + var qid string + var oids []string + var correct *bool + var ms *int + if err := rows.Scan(&qid, &oids, &correct, &ms); err != nil { + return nil, fmt.Errorf("scan answer: %w", err) + } + a := per[qid] + if a == nil { + a = &agg{} + per[qid] = a + } + a.answered++ + if correct != nil && *correct { + a.correct++ + } + if ms != nil { + a.times = append(a.times, *ms) + } + for _, oid := range oids { + picks[oid]++ + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]model.QuizQuestionStat, 0, len(qs)) + for _, q := range qs { + st := model.QuizQuestionStat{ID: q.ID, Ord: q.Ord, Prompt: q.Prompt, Multi: q.Multi} + if a := per[q.ID]; a != nil { + st.Answered, st.Correct = a.answered, a.correct + if n := len(a.times); n > 0 { + sum := 0 + for _, t := range a.times { + sum += t + } + st.AvgMs = sum / n + sort.Ints(a.times) + st.MedianMs = a.times[n/2] + } + } + for _, o := range q.Options { + st.Options = append(st.Options, model.QuizOptionStat{QuizOption: o, Picks: picks[o.ID]}) + } + out = append(out, st) + } + return out, nil +} + +// ── Taking a quiz ───────────────────────────────────────────────────────── + +// PublishedForPath returns the newest live quiz on a content page, or nil. +func (r *QuizRepo) PublishedForPath(ctx context.Context, path string) (*model.Quiz, error) { + var q model.Quiz + err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+` + FROM quizzes q + WHERE q.target_path = $1 AND q.deleted_at IS NULL AND q.published_at IS NOT NULL + ORDER BY q.published_at DESC LIMIT 1`, path), &q) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("quiz for path: %w", err) + } + return &q, nil +} + +// ListPublished returns every live quiz, newest first. +func (r *QuizRepo) ListPublished(ctx context.Context) ([]model.Quiz, error) { + rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+` + FROM quizzes q + WHERE q.deleted_at IS NULL AND q.published_at IS NOT NULL + ORDER BY q.published_at DESC`) + if err != nil { + return nil, fmt.Errorf("list published quizzes: %w", err) + } + defer rows.Close() + out := []model.Quiz{} + for rows.Next() { + var q model.Quiz + if err := scanQuiz(rows, &q); err != nil { + return nil, fmt.Errorf("scan quiz: %w", err) + } + out = append(out, q) + } + return out, rows.Err() +} + +// Statuses returns the caller's progress on each of the given quizzes. +// Quizzes they haven't touched are simply absent. +func (r *QuizRepo) Statuses(ctx context.Context, userID string, quizIDs []string) (map[string]model.QuizStatus, error) { + out := map[string]model.QuizStatus{} + if userID == "" || len(quizIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT a.quiz_id, a.completed_at IS NOT NULL, a.score, a.total, + (SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL) + FROM quiz_attempts a + WHERE a.user_id = $1 AND a.quiz_id = ANY($2)`, userID, quizIDs) + if err != nil { + return nil, fmt.Errorf("quiz statuses: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + var done bool + var score, total, answered int + if err := rows.Scan(&id, &done, &score, &total, &answered); err != nil { + return nil, fmt.Errorf("scan status: %w", err) + } + st := model.QuizStatus{State: model.QuizInProgress, Answered: answered, Total: total} + if done { + st.State = model.QuizCompleted + s := score + st.Score = &s + } + out[id] = st + } + return out, rows.Err() +} + +type attemptRow struct { + ID string + StartedAt time.Time + CompletedAt *time.Time + Score int + Total int +} + +func (r *QuizRepo) attempt(ctx context.Context, quizID, userID string) (*attemptRow, error) { + var a attemptRow + err := r.pg.Pool.QueryRow(ctx, ` + SELECT id, started_at, completed_at, score, total + FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID, + ).Scan(&a.ID, &a.StartedAt, &a.CompletedAt, &a.Score, &a.Total) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrQuizNotStarted + } + if err != nil { + return nil, fmt.Errorf("load attempt: %w", err) + } + return &a, nil +} + +// Start opens the caller's attempt. Idempotent: starting again resumes. +func (r *QuizRepo) Start(ctx context.Context, quizID, userID string, total int) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO quiz_attempts (quiz_id, user_id, total) VALUES ($1, $2, $3) + ON CONFLICT (quiz_id, user_id) DO NOTHING`, quizID, userID, total) + if err != nil { + return fmt.Errorf("start attempt: %w", err) + } + return nil +} + +// Next returns the first unanswered question and stamps the moment it was +// served. The stamp is set ONCE: fetching the same question again — a reload, +// a second tab — doesn't restart its clock. +func (r *QuizRepo) Next(ctx context.Context, quizID, userID string) (*model.QuizPlay, error) { + a, err := r.attempt(ctx, quizID, userID) + if err != nil { + return nil, err + } + play := &model.QuizPlay{QuizID: quizID, Total: a.Total} + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND answered_at IS NOT NULL`, + a.ID).Scan(&play.Answered); err != nil { + return nil, fmt.Errorf("count answered: %w", err) + } + if a.CompletedAt != nil { + play.Done = true + return play, nil + } + + var qid string + err = r.pg.Pool.QueryRow(ctx, ` + SELECT qq.id FROM quiz_questions qq + WHERE qq.quiz_id = $1 + AND NOT EXISTS (SELECT 1 FROM quiz_answers x + WHERE x.attempt_id = $2 AND x.question_id = qq.id + AND x.answered_at IS NOT NULL) + ORDER BY qq.ord LIMIT 1`, quizID, a.ID).Scan(&qid) + if errors.Is(err, pgx.ErrNoRows) { + // Every question answered but not yet marked complete — finish it. + if err := r.complete(ctx, a.ID); err != nil { + return nil, err + } + play.Done = true + return play, nil + } + if err != nil { + return nil, fmt.Errorf("next question: %w", err) + } + if _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO quiz_answers (attempt_id, question_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, a.ID, qid); err != nil { + return nil, fmt.Errorf("stamp question: %w", err) + } + + qs, err := r.questions(ctx, quizID, false) + if err != nil { + return nil, err + } + for i := range qs { + if qs[i].ID == qid { + play.Question = &qs[i] + // The explanation discusses the answer; it belongs on the + // results screen, not beside the question being answered. + play.Question.Explanation = "" + } + } + return play, nil +} + +// Answer records the caller's choice on the CURRENT question: one option, or +// for a multi question every option they think applies. +// +// The question must have been served (Next stamped it), which enforces order +// and gives the server its own start time. Response time is shown→answered, +// both server clocks. An answer can't be changed once given. A multi answer +// is correct only when the chosen set equals the correct set exactly. +func (r *QuizRepo) Answer(ctx context.Context, quizID, userID, questionID string, optionIDs []string) error { + a, err := r.attempt(ctx, quizID, userID) + if err != nil { + return err + } + if a.CompletedAt != nil { + return ErrQuizAlreadyAnswer + } + + chosen := map[string]bool{} + for _, id := range optionIDs { + chosen[id] = true + } + + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin answer tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + rows, err := tx.Query(ctx, ` + SELECT qq.multi, o.id, o.is_correct FROM quiz_options o + JOIN quiz_questions qq ON qq.id = o.question_id + WHERE o.question_id = $1 AND qq.quiz_id = $2`, questionID, quizID) + if err != nil { + return fmt.Errorf("check options: %w", err) + } + var multi bool + known, wrongPick, missed := 0, false, false + for rows.Next() { + var oid string + var isCorrect bool + if err := rows.Scan(&multi, &oid, &isCorrect); err != nil { + rows.Close() + return fmt.Errorf("scan option: %w", err) + } + if chosen[oid] { + known++ + wrongPick = wrongPick || !isCorrect + } else if isCorrect { + missed = true + } + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("check options: %w", err) + } + if known != len(chosen) { + // Something chosen isn't an option of this question (or the question + // isn't part of this quiz at all). + return ErrQuizBadOption + } + if len(chosen) == 0 || (!multi && len(chosen) != 1) { + return ErrQuizChoiceCount + } + correct := !wrongPick && !missed + ids := make([]string, 0, len(chosen)) + for _, id := range optionIDs { + if chosen[id] { + ids = append(ids, id) + delete(chosen, id) // keep the first of any duplicate + } + } + + var answeredAt *time.Time + err = tx.QueryRow(ctx, ` + SELECT answered_at FROM quiz_answers + WHERE attempt_id = $1 AND question_id = $2 FOR UPDATE`, a.ID, questionID).Scan(&answeredAt) + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuizNotCurrent + } + if err != nil { + return fmt.Errorf("load answer row: %w", err) + } + if answeredAt != nil { + return ErrQuizAlreadyAnswer + } + + if _, err := tx.Exec(ctx, ` + UPDATE quiz_answers + SET option_ids = $3::uuid[], answered_at = now(), is_correct = $4, + response_ms = GREATEST(0, (EXTRACT(EPOCH FROM (now() - shown_at)) * 1000)::int) + WHERE attempt_id = $1 AND question_id = $2`, + a.ID, questionID, ids, correct); err != nil { + return fmt.Errorf("record answer: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit answer: %w", err) + } + + // Finish the attempt once nothing is left unanswered. + var remaining int + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT count(*) FROM quiz_questions qq + WHERE qq.quiz_id = $1 AND NOT EXISTS ( + SELECT 1 FROM quiz_answers x WHERE x.attempt_id = $2 + AND x.question_id = qq.id AND x.answered_at IS NOT NULL)`, + quizID, a.ID).Scan(&remaining); err != nil { + return fmt.Errorf("count remaining: %w", err) + } + if remaining == 0 { + return r.complete(ctx, a.ID) + } + return nil +} + +func (r *QuizRepo) complete(ctx context.Context, attemptID string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE quiz_attempts SET completed_at = now(), + score = (SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND is_correct) + WHERE id = $1 AND completed_at IS NULL`, attemptID) + if err != nil { + return fmt.Errorf("complete attempt: %w", err) + } + return nil +} + +// Result returns an attempt with the answers revealed. +// +// allowIncomplete is false on the user's path, which refuses until the attempt +// is finished — otherwise a result would be a way to peek at the answers. The +// admin passes true to inspect an attempt still in progress. +func (r *QuizRepo) Result(ctx context.Context, quizID, userID string, allowIncomplete bool) (*model.QuizResult, error) { + a, err := r.attempt(ctx, quizID, userID) + if err != nil { + return nil, err + } + if a.CompletedAt == nil && !allowIncomplete { + return nil, ErrQuizNotFinished + } + quiz, err := r.Get(ctx, quizID, true) + if err != nil { + return nil, err + } + res := &model.QuizResult{ + Score: a.Score, Total: a.Total, StartedAt: a.StartedAt, CompletedAt: a.CompletedAt, + } + + rows, err := r.pg.Pool.Query(ctx, ` + SELECT question_id, COALESCE(option_ids::text[], '{}'), COALESCE(is_correct, false), response_ms + FROM quiz_answers WHERE attempt_id = $1`, a.ID) + if err != nil { + return nil, fmt.Errorf("load result answers: %w", err) + } + defer rows.Close() + type ans struct { + options []string + correct bool + ms *int + } + got := map[string]ans{} + for rows.Next() { + var qid string + var x ans + if err := rows.Scan(&qid, &x.options, &x.correct, &x.ms); err != nil { + return nil, fmt.Errorf("scan result answer: %w", err) + } + got[qid] = x + } + if err := rows.Err(); err != nil { + return nil, err + } + + for _, q := range quiz.Questions { + item := model.QuizResultItem{QuizQuestion: q, ChosenOptionIDs: []string{}} + if x, ok := got[q.ID]; ok { + item.ChosenOptionIDs, item.IsCorrect, item.ResponseMs = x.options, x.correct, x.ms + if x.ms != nil { + res.TotalMs += *x.ms + } + } + res.Items = append(res.Items, item) + } + quiz.Questions = nil + res.Quiz = *quiz + return res, nil +} diff --git a/internal/repository/staff_repo.go b/internal/repository/staff_repo.go new file mode 100644 index 0000000..c1aeef4 --- /dev/null +++ b/internal/repository/staff_repo.go @@ -0,0 +1,241 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +var ( + ErrNotStaff = errors.New("not a staff member") + ErrRoleNotFound = errors.New("role not found") + ErrRoleKeyTaken = errors.New("a role with that name already exists") + ErrRoleInUse = errors.New("role is still assigned to staff") +) + +// SuperadminKey is the seeded role that holds the wildcard. +const SuperadminKey = "superadmin" + +type StaffRepo struct{ pg *Postgres } + +func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} } + +// Access loads the caller's role and permissions. ErrNotStaff when the account +// holds no admin role at all. +func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) { + var a model.StaffAccess + err := r.pg.Pool.QueryRow(ctx, ` + SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL + FROM users u + JOIN admin_roles ro ON ro.id = u.admin_role_id + WHERE u.id = $1 AND u.banned_at IS NULL`, userID, + ).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotStaff + } + if err != nil { + return nil, fmt.Errorf("staff access: %w", err) + } + return &a, nil +} + +const staffSelect = ` + SELECT u.id, u.handle, u.name, u.email, u.avatar_url, + ro.id, ro.key, ro.name, u.admin_blocked_at + FROM users u + JOIN admin_roles ro ON ro.id = u.admin_role_id` + +func scanStaff(row pgx.Row, m *model.StaffMember) error { + return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL, + &m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt) +} + +// ListStaff returns every account holding a role, superadmins first. +func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) { + rows, err := r.pg.Pool.Query(ctx, staffSelect+` + ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`) + if err != nil { + return nil, fmt.Errorf("list staff: %w", err) + } + defer rows.Close() + out := []model.StaffMember{} + for rows.Next() { + var m model.StaffMember + if err := scanStaff(rows, &m); err != nil { + return nil, fmt.Errorf("scan staff: %w", err) + } + out = append(out, m) + } + return out, rows.Err() +} + +// StaffMember loads one staff member. ErrNotStaff if they hold no role. +func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) { + var m model.StaffMember + err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotStaff + } + if err != nil { + return nil, fmt.Errorf("staff member: %w", err) + } + return &m, nil +} + +// SetRole grants or changes a role. A nil roleID revokes staff access and +// clears any block, so a later re-grant starts clean. +func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error { + tag, err := r.pg.Pool.Exec(ctx, ` + UPDATE users + SET admin_role_id = $2, + admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END, + updated_at = now() + WHERE id = $1`, userID, roleID) + if err != nil { + return fmt.Errorf("set staff role: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserGone + } + return nil +} + +// SetBlocked suspends or restores a staff member's panel access. +func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE users + SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END + WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked) + if err != nil { + return fmt.Errorf("set staff blocked: %w", err) + } + return nil +} + +// ActiveSuperadmins counts superadmins who aren't blocked — the floor that +// stops the last one from being demoted, revoked or blocked. +func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) { + var n int + err := r.pg.Pool.QueryRow(ctx, ` + SELECT count(*) FROM users u + JOIN admin_roles ro ON ro.id = u.admin_role_id + WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`, + SuperadminKey).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count superadmins: %w", err) + } + return n, nil +} + +// UserIDByHandle resolves a handle for granting a role. +func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) { + var id string + err := r.pg.Pool.QueryRow(ctx, + `SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrUserGone + } + if err != nil { + return "", fmt.Errorf("resolve handle: %w", err) + } + return id, nil +} + +// ── Roles ───────────────────────────────────────────────────────────────── + +func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system, + ro.permissions, ro.created_at, + (SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id) + FROM admin_roles ro + ORDER BY ro.is_system DESC, + array_position(ARRAY['superadmin','admin','moderator'], ro.key::text), + ro.name`) + if err != nil { + return nil, fmt.Errorf("list roles: %w", err) + } + defer rows.Close() + out := []model.AdminRole{} + for rows.Next() { + var ro model.AdminRole + if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, + &ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil { + return nil, fmt.Errorf("scan role: %w", err) + } + out = append(out, ro) + } + return out, rows.Err() +} + +func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) { + var ro model.AdminRole + err := r.pg.Pool.QueryRow(ctx, ` + SELECT id, key, name, description, is_system, permissions, created_at + FROM admin_roles WHERE id = $1`, id, + ).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrRoleNotFound + } + if err != nil { + return nil, fmt.Errorf("load role: %w", err) + } + return &ro, nil +} + +func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) { + var ro model.AdminRole + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO admin_roles (key, name, description, permissions) + VALUES ($1, $2, $3, $4) + RETURNING id, key, name, description, is_system, permissions, created_at`, + key, name, description, perms, + ).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt) + if err != nil { + if isUniqueViolation(err, "admin_roles_key_key") { + return nil, ErrRoleKeyTaken + } + return nil, fmt.Errorf("create role: %w", err) + } + return &ro, nil +} + +// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a +// bug higher up still can't rewrite superadmin. +func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error { + tag, err := r.pg.Pool.Exec(ctx, ` + UPDATE admin_roles + SET name = $2, description = $3, permissions = $4, updated_at = now() + WHERE id = $1 AND NOT is_system`, id, name, description, perms) + if err != nil { + return fmt.Errorf("update role: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrRoleNotFound + } + return nil +} + +// DeleteRole removes an unused CUSTOM role. +func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error { + var members int + if err := r.pg.Pool.QueryRow(ctx, + `SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil { + return fmt.Errorf("count role members: %w", err) + } + if members > 0 { + return ErrRoleInUse + } + tag, err := r.pg.Pool.Exec(ctx, + `DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id) + if err != nil { + return fmt.Errorf("delete role: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrRoleNotFound + } + return nil +} diff --git a/internal/repository/stats_repo.go b/internal/repository/stats_repo.go index a85e0e5..09bf314 100644 --- a/internal/repository/stats_repo.go +++ b/internal/repository/stats_repo.go @@ -39,7 +39,8 @@ type CategoryCount struct { // the table sizes are modest enough that chasing parallelism here // would buy sub-ms wins for added complexity. func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) { - s := &StatsSnapshot{} + // Non-nil so an empty table serialises as [] rather than null. + s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}} sevenAgo := time.Now().Add(-7 * 24 * time.Hour) scalars := []struct { diff --git a/internal/server/server.go b/internal/server/server.go index 172291b..f4fb606 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/config" "github.com/prabhupadasays/spsays-bff/internal/handler" "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/internal/worker" @@ -78,6 +79,9 @@ type Server struct { statsH *handler.StatsHandler adminH *handler.AdminHandler broadcastH *handler.BroadcastHandler + staffRepo *repository.StaffRepo + staffH *handler.StaffHandler + quizH *handler.QuizHandler // Auth state (nil when Postgres is unavailable — /auth and /users return 503) authIssuer *auth.Issuer @@ -224,6 +228,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { vaniRepo := repository.NewVaniRepo(s.pg, s.mongo) s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger)) + // Quizzes reuse the vāṇī content lookups to resolve a pasted link into a + // target title and kind. + s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo)) + // OAuth — wired only when at least one provider is configured. The // registry skips providers with missing creds, so partial configs (e.g. // Google only, no Facebook) work out of the box. @@ -344,7 +352,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // Gated by RequireAuth + RequireAdmin in the router. adminRepo := repository.NewAdminRepo(s.pg) adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL) - s.adminH = handler.NewAdminHandler(adminSvc) + // Admin RBAC: who is staff, with which permissions. + s.staffRepo = repository.NewStaffRepo(s.pg) + s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo) + s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL)) // Broadcasts — admin announcements shown once per user. No notification // rows: the modal is the surface, and push plus the realtime nudge only @@ -479,8 +490,14 @@ func (s *Server) setupRouter() { // CORS — the main frontend plus, when configured, the admin panel (a // separate origin). Empty origins are filtered so the list never widens // unintentionally. + adminURL := s.cfg.AdminURL + if adminURL == "" && s.cfg.Env == "development" { + // The admin app's dev server (`next dev -p 3001`), so local admin + // work needs no extra config. Production must set ADMIN_URL. + adminURL = "http://localhost:3001" + } allowedOrigins := []string{} - for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} { + for _, o := range []string{s.cfg.FrontendURL, adminURL} { if o != "" { allowedOrigins = append(allowedOrigins, o) } @@ -708,6 +725,23 @@ 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. + 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.Post("/{id}/start", s.quizH.Start) + r.Post("/{id}/answer", s.quizH.Answer) + r.Get("/{id}/result", s.quizH.Result) + }) + }) + // --- Auth --- // Public endpoints (no JWT required) for sign-up, log-in, password reset, // and email verification. The /auth/me + /auth/resend-verification routes @@ -1150,52 +1184,87 @@ func (s *Server) setupRouter() { return } r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) - r.Use(middleware.RequireAdmin(s.userRepo)) - r.Get("/stats", s.adminH.Stats) - r.Get("/users", s.adminH.ListUsers) - r.Post("/users/{id}/notify", s.adminH.NotifyUser) - r.Post("/users/{id}/ban", s.adminH.BanUser) - r.Delete("/users/{id}/ban", s.adminH.UnbanUser) - r.Post("/users/{id}/limits", s.adminH.SetUserLimits) - // Direct-disciple linking: mark an account as one of Śrīla - // Prabhupāda's initiated disciples (or clear the mark). - r.Get("/disciples", s.adminH.ListDisciples) + // Staff only past this point; each route then names the one + // permission it needs. A route without a permission is reachable + // by every staff member, so only /me is left bare. + r.Use(middleware.RequireStaff(s.staffRepo)) + can := func(perm string) chi.Router { + return r.With(middleware.RequirePermission(perm)) + } - // Announcements composed and published from the admin panel. - r.Get("/vani", s.vaniH.Schedule) - r.Post("/vani", s.vaniH.Set) - r.Post("/vani/by-url", s.vaniH.SetByURL) - r.Delete("/vani/{day}", s.vaniH.Clear) + r.Get("/me", s.staffH.Me) - r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) - r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone) - r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift) - r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage) - r.Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift) + // Staff and roles — superadmins only. + can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog) + can(rbac.ManageStaff).Get("/staff", s.staffH.List) + can(rbac.ManageStaff).Post("/staff", s.staffH.Grant) + can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole) + can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke) + can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block) + can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock) + can(rbac.ManageStaff).Get("/roles", s.staffH.Roles) + can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole) + can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole) + can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole) - r.Get("/broadcasts", s.broadcastH.List) - r.Post("/broadcasts", s.broadcastH.Create) - r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner) - r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish) - r.Delete("/broadcasts/{id}", s.broadcastH.Delete) - r.Post("/users/{id}/disciple", s.adminH.LinkDisciple) - r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple) - r.Get("/posts", s.adminH.ListPosts) - r.Post("/posts/{id}/hide", s.adminH.HidePost) - r.Delete("/posts/{id}/hide", s.adminH.UnhidePost) - r.Delete("/posts/{id}", s.adminH.DeletePost) - r.Post("/posts/{id}/restore", s.adminH.RestorePost) - r.Delete("/comments/{id}", s.adminH.DeleteComment) - r.Post("/comments/{id}/restore", s.adminH.RestoreComment) - r.Get("/collections", s.adminH.ListCollections) - r.Get("/feedback", s.adminH.ListFeedback) - r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) - r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback) - r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) - r.Get("/reports", s.adminH.ListReports) - r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) - r.Get("/short-urls", s.adminH.ListShortURLs) - r.Get("/short-urls/stats", s.adminH.ShortURLStats) + can("stats:read").Get("/stats", s.adminH.Stats) + + can("users:read").Get("/users", s.adminH.ListUsers) + can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser) + can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser) + can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser) + can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits) + can("users:read").Get("/disciples", s.adminH.ListDisciples) + can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple) + can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple) + + can("vani:read").Get("/vani", s.vaniH.Schedule) + can("vani:write").Post("/vani", s.vaniH.Set) + can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL) + can("vani:write").Delete("/vani/{day}", s.vaniH.Clear) + + can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) + can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone) + can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift) + can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage) + can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift) + + can("broadcasts:read").Get("/broadcasts", s.broadcastH.List) + can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create) + can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner) + can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish) + can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete) + + can("posts:moderate").Get("/posts", s.adminH.ListPosts) + can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost) + can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost) + can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost) + can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost) + can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment) + can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment) + can("collections:read").Get("/collections", s.adminH.ListCollections) + + can("feedback:read").Get("/feedback", s.adminH.ListFeedback) + can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) + can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback) + can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) + + can("reports:read").Get("/reports", s.adminH.ListReports) + can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) + + can("quizzes:read").Get("/quizzes", s.quizH.AdminList) + can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet) + can("quizzes:write").Post("/quizzes", s.quizH.Create) + can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update) + can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish) + can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish) + can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete) + 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("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 new file mode 100644 index 0000000..e4421ee --- /dev/null +++ b/internal/service/quiz_service.go @@ -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) +} diff --git a/internal/service/quiz_service_test.go b/internal/service/quiz_service_test.go new file mode 100644 index 0000000..c359378 --- /dev/null +++ b/internal/service/quiz_service_test.go @@ -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) + } + } +} diff --git a/internal/service/staff_email.go b/internal/service/staff_email.go new file mode 100644 index 0000000..0e575b2 --- /dev/null +++ b/internal/service/staff_email.go @@ -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(`
+

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

+ +

You have been promoted to {{.RoleName}} on Prabhupāda Says.

+ +

Log in with your email and password at + {{.AdminURL}}.

+ +

+ Open the admin panel +

+ +

For any queries, contact your co-ordinator.

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) diff --git a/internal/service/staff_service.go b/internal/service/staff_service.go new file mode 100644 index 0000000..5dfb7f4 --- /dev/null +++ b/internal/service/staff_service.go @@ -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) +} diff --git a/internal/service/staff_service_test.go b/internal/service/staff_service_test.go new file mode 100644 index 0000000..40c1e5f --- /dev/null +++ b/internal/service/staff_service_test.go @@ -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 Moderator", "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): + } +} diff --git a/migrations/0056_admin_rbac.down.sql b/migrations/0056_admin_rbac.down.sql new file mode 100644 index 0000000..b978de4 --- /dev/null +++ b/migrations/0056_admin_rbac.down.sql @@ -0,0 +1,7 @@ +-- Restore the old flag for anyone who still has a role, so a rollback keeps +-- them in the panel. +UPDATE users SET role = 'admin' WHERE admin_role_id IS NOT NULL AND admin_blocked_at IS NULL; +DROP INDEX IF EXISTS users_admin_role_idx; +ALTER TABLE users DROP COLUMN IF EXISTS admin_blocked_at; +ALTER TABLE users DROP COLUMN IF EXISTS admin_role_id; +DROP TABLE IF EXISTS admin_roles; diff --git a/migrations/0056_admin_rbac.up.sql b/migrations/0056_admin_rbac.up.sql new file mode 100644 index 0000000..22f90d0 --- /dev/null +++ b/migrations/0056_admin_rbac.up.sql @@ -0,0 +1,60 @@ +-- Role-based access for the admin panel. +-- +-- Until now admin access was one flag: users.role = 'admin'. This replaces it +-- with named roles carrying explicit permissions, so a moderator can resolve +-- reports without also being able to ban users or gift stickers to everyone. +-- +-- Three system roles are seeded and cannot be edited or deleted; superadmins +-- can define further custom roles from any subset of the permission catalog +-- (which lives in code — see internal/rbac). The one thing a custom role can +-- never hold is admins:manage: granting, changing, revoking and blocking staff +-- stays with superadmins. +CREATE TABLE IF NOT EXISTS admin_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(40) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL, + description TEXT NOT NULL DEFAULT '', + -- System roles are seeded below and are read-only. + is_system BOOLEAN NOT NULL DEFAULT FALSE, + -- "resource:action" strings, or '*' for everything (superadmin only). + permissions TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO admin_roles (key, name, description, is_system, permissions) VALUES + ('superadmin', 'Superadmin', + 'Everything, including managing other staff.', TRUE, ARRAY['*']), + ('admin', 'Admin', + 'Runs the platform day to day. Cannot manage staff.', TRUE, ARRAY[ + 'stats:read','users:read','users:notify','users:ban','users:limits', + 'disciples:manage','posts:moderate','comments:moderate','collections:read', + 'feedback:read','feedback:reply','feedback:resolve', + 'reports:read','reports:resolve','vani:read','vani:write', + 'rewards:read','rewards:write','rewards:send','rewards:delete', + 'broadcasts:read','broadcasts:write','broadcasts:publish', + 'short_urls:read','quizzes:read','quizzes:write','quizzes:delete','quizzes:results']), + ('moderator', 'Moderator', + 'Keeps the community healthy: reports, moderation and feedback.', TRUE, ARRAY[ + 'stats:read','users:read','posts:moderate','comments:moderate', + 'collections:read','feedback:read','feedback:reply','feedback:resolve', + 'reports:read','reports:resolve']) +ON CONFLICT (key) DO NOTHING; + +-- Staff membership. NULL = not staff. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS admin_role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT; + +-- A superadmin can suspend another staff member's panel access without taking +-- their role away, so restoring them is one click rather than a re-grant. +ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_blocked_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS users_admin_role_idx + ON users (admin_role_id) WHERE admin_role_id IS NOT NULL; + +-- Everyone who was an admin under the old single flag becomes a superadmin: +-- they had unrestricted access before, so anything less would lock existing +-- staff out of the panel the moment this deploys. +UPDATE users +SET admin_role_id = (SELECT id FROM admin_roles WHERE key = 'superadmin') +WHERE role = 'admin' AND admin_role_id IS NULL; diff --git a/migrations/0057_quizzes.down.sql b/migrations/0057_quizzes.down.sql new file mode 100644 index 0000000..440dac6 --- /dev/null +++ b/migrations/0057_quizzes.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS quiz_answers; +DROP TABLE IF EXISTS quiz_attempts; +DROP TABLE IF EXISTS quiz_options; +DROP TABLE IF EXISTS quiz_questions; +DROP TABLE IF EXISTS quizzes; diff --git a/migrations/0057_quizzes.up.sql b/migrations/0057_quizzes.up.sql new file mode 100644 index 0000000..885cac6 --- /dev/null +++ b/migrations/0057_quizzes.up.sql @@ -0,0 +1,88 @@ +-- Quizzes on the library's content. +-- +-- A quiz is attached to a piece of content by its app path — a book chapter +-- (/books/bg/2), or a lecture or conversation (/transcriptions/{slug}) — or to +-- nothing at all, for a stand-alone quiz. The path is what the content page +-- already knows about itself, so it can ask "is there a quiz for me?" without +-- the content's Mongo id ever reaching Postgres. +CREATE TABLE IF NOT EXISTS quizzes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(200) NOT NULL, + -- Light formatting markers, rendered like the rest of the admin text. + description TEXT NOT NULL DEFAULT '', + -- chapter | lecture | conversation | standalone + target_kind VARCHAR(16) NOT NULL DEFAULT 'standalone', + -- App path of the content, NULL for a stand-alone quiz. + target_path TEXT, + -- Display name of the content, captured when the quiz is made. + target_title TEXT NOT NULL DEFAULT '', + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Soft delete: participants' results stay intact for the record. + deleted_at TIMESTAMPTZ, + CONSTRAINT quizzes_target_kind_check + CHECK (target_kind IN ('chapter', 'lecture', 'conversation', 'standalone')), + CONSTRAINT quizzes_target_consistent + CHECK ((target_kind = 'standalone') = (target_path IS NULL)) +); + +-- "Is there a quiz for this page?" — asked by every content page load. +CREATE INDEX IF NOT EXISTS quizzes_target_idx + ON quizzes (target_path, published_at DESC) + WHERE deleted_at IS NULL AND published_at IS NOT NULL; + +CREATE TABLE IF NOT EXISTS quiz_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + ord SMALLINT NOT NULL, + prompt TEXT NOT NULL, + -- Optional note shown with the answer on the results screen. + explanation TEXT NOT NULL DEFAULT '', + UNIQUE (quiz_id, ord) +); + +CREATE TABLE IF NOT EXISTS quiz_options ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE, + ord SMALLINT NOT NULL, + body TEXT NOT NULL, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (question_id, ord), + -- Five options at most; the service also enforces two at least and + -- exactly one correct. + CONSTRAINT quiz_options_ord_check CHECK (ord BETWEEN 0 AND 4) +); + +-- One attempt per person per quiz: it is what "Continue" resumes and what +-- "View results" shows. +CREATE TABLE IF NOT EXISTS quiz_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + score INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + UNIQUE (quiz_id, user_id) +); + +CREATE INDEX IF NOT EXISTS quiz_attempts_user_idx ON quiz_attempts (user_id); + +-- One row per question an attempt has reached. +-- +-- Timing is measured by the SERVER: shown_at is stamped when the question is +-- handed to the client, answered_at when the answer arrives. A client-reported +-- duration could be anything; these two timestamps can't be edited from the +-- browser. +CREATE TABLE IF NOT EXISTS quiz_answers ( + attempt_id UUID NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE, + option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL, + shown_at TIMESTAMPTZ NOT NULL DEFAULT now(), + answered_at TIMESTAMPTZ, + response_ms INTEGER, + is_correct BOOLEAN, + PRIMARY KEY (attempt_id, question_id) +); diff --git a/migrations/0058_quiz_multi_choice.down.sql b/migrations/0058_quiz_multi_choice.down.sql new file mode 100644 index 0000000..546e87a --- /dev/null +++ b/migrations/0058_quiz_multi_choice.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL; +-- Lossy for multi answers: keeps the first chosen option only. +UPDATE quiz_answers SET option_id = option_ids[1] WHERE option_ids IS NOT NULL; +ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_ids; +ALTER TABLE quiz_questions DROP COLUMN IF EXISTS multi; diff --git a/migrations/0058_quiz_multi_choice.up.sql b/migrations/0058_quiz_multi_choice.up.sql new file mode 100644 index 0000000..f110b9a --- /dev/null +++ b/migrations/0058_quiz_multi_choice.up.sql @@ -0,0 +1,14 @@ +-- Multiple-choice questions with more than one correct answer. +-- +-- A question marked multi asks the reader to "select all that apply"; it is +-- scored correct only when the chosen set equals the correct set exactly. A +-- single-answer question keeps exactly one correct option. +ALTER TABLE quiz_questions ADD COLUMN IF NOT EXISTS multi BOOLEAN NOT NULL DEFAULT FALSE; + +-- An answer is now a set of options. Arrays can't carry a foreign key; that +-- is safe here because a quiz's options are frozen once anyone has answered, +-- and options only ever go away with their whole question. +ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_ids UUID[]; +UPDATE quiz_answers SET option_ids = ARRAY[option_id] +WHERE option_id IS NOT NULL AND option_ids IS NULL; +ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_id;