feat: quiz and rbac
This commit is contained in:
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
|
|||||||
|
|
||||||
# Frontend (CORS + email link base)
|
# Frontend (CORS + email link base)
|
||||||
FRONTEND_URL=http://localhost:3000
|
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_ID=
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
@@ -15,9 +17,15 @@ import (
|
|||||||
|
|
||||||
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||||
// the router, so every method here can assume an admin caller.
|
// 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.
|
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||||
func pageParams(r *http.Request) (q string, limit, offset int) {
|
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) {
|
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 {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "could not update account")
|
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,17 +1,38 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
type staffCtxKey struct{}
|
||||||
// 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
|
// StaffContext is the caller's resolved admin access, set by RequireStaff.
|
||||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
type StaffContext struct {
|
||||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := UserID(r.Context())
|
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")
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.Role != "admin" {
|
if access.Blocked {
|
||||||
response.Error(w, http.StatusForbidden, "admin access required")
|
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
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -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) }
|
||||||
+19
-17
@@ -17,8 +17,10 @@ type AdminUser struct {
|
|||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
BannedAt *time.Time `json:"banned_at"`
|
BannedAt *time.Time `json:"banned_at"`
|
||||||
PostCount int `json:"post_count"`
|
// AdminRole is the staff role key, nil for ordinary accounts.
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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;
|
// Connected auth methods. HasPassword means email+password login is set up;
|
||||||
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
// 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.
|
// AdminPost is a row in the admin posts table.
|
||||||
type AdminPost struct {
|
type AdminPost struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
SourceTitle string `json:"source_title"`
|
SourceTitle string `json:"source_title"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
IsHidden bool `json:"is_hidden"`
|
IsHidden bool `json:"is_hidden"`
|
||||||
DeletedAt *time.Time `json:"deleted_at"`
|
DeletedAt *time.Time `json:"deleted_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
AuthorName string `json:"author_name"`
|
AuthorName string `json:"author_name"`
|
||||||
AuthorHandle string `json:"author_handle"`
|
AuthorHandle string `json:"author_handle"`
|
||||||
LikesCount int `json:"likes_count"`
|
LikesCount int `json:"likes_count"`
|
||||||
CommentsCount int `json:"comments_count"`
|
CommentsCount int `json:"comments_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCollection is a row in the admin collections table.
|
// 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).
|
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||||
type AdminFeedback struct {
|
type AdminFeedback struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ContactEmail *string `json:"contact_email"`
|
ContactEmail *string `json:"contact_email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||||
ResolvedAt *time.Time `json:"resolved_at"`
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
RepliedAt *time.Time `json:"replied_at"`
|
RepliedAt *time.Time `json:"replied_at"`
|
||||||
ReplyMessage *string `json:"reply_message"`
|
ReplyMessage *string `json:"reply_message"`
|
||||||
AuthorHandle *string `json:"author_handle"`
|
AuthorHandle *string `json:"author_handle"`
|
||||||
AuthorName *string `json:"author_name"`
|
AuthorName *string `json:"author_name"`
|
||||||
AuthorEmail *string `json:"author_email"`
|
AuthorEmail *string `json:"author_email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminReport is a unified projection over the three report tables
|
// AdminReport is a unified projection over the three report tables
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||||
u.disciple_id,
|
u.disciple_id,
|
||||||
|
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
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.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||||
&u.HasPassword, &u.Providers,
|
&u.HasPassword, &u.Providers,
|
||||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
&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)
|
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||||
}
|
}
|
||||||
// Resolve plan default + overrides so the panel shows what's in force.
|
// Resolve plan default + overrides so the panel shows what's in force.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ type CategoryCount struct {
|
|||||||
// the table sizes are modest enough that chasing parallelism here
|
// the table sizes are modest enough that chasing parallelism here
|
||||||
// would buy sub-ms wins for added complexity.
|
// would buy sub-ms wins for added complexity.
|
||||||
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
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)
|
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
|
||||||
scalars := []struct {
|
scalars := []struct {
|
||||||
|
|||||||
+114
-45
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"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/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||||
@@ -78,6 +79,9 @@ type Server struct {
|
|||||||
statsH *handler.StatsHandler
|
statsH *handler.StatsHandler
|
||||||
adminH *handler.AdminHandler
|
adminH *handler.AdminHandler
|
||||||
broadcastH *handler.BroadcastHandler
|
broadcastH *handler.BroadcastHandler
|
||||||
|
staffRepo *repository.StaffRepo
|
||||||
|
staffH *handler.StaffHandler
|
||||||
|
quizH *handler.QuizHandler
|
||||||
|
|
||||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
authIssuer *auth.Issuer
|
authIssuer *auth.Issuer
|
||||||
@@ -224,6 +228,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||||
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
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
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
// registry skips providers with missing creds, so partial configs (e.g.
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
// Google only, no Facebook) work out of the box.
|
// 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.
|
// Gated by RequireAuth + RequireAdmin in the router.
|
||||||
adminRepo := repository.NewAdminRepo(s.pg)
|
adminRepo := repository.NewAdminRepo(s.pg)
|
||||||
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
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
|
// Broadcasts — admin announcements shown once per user. No notification
|
||||||
// rows: the modal is the surface, and push plus the realtime nudge only
|
// 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
|
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||||
// separate origin). Empty origins are filtered so the list never widens
|
// separate origin). Empty origins are filtered so the list never widens
|
||||||
// unintentionally.
|
// 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{}
|
allowedOrigins := []string{}
|
||||||
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
|
||||||
if o != "" {
|
if o != "" {
|
||||||
allowedOrigins = append(allowedOrigins, 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.
|
// Vāṇī of the Day — public daily content pick for the home page.
|
||||||
r.Get("/vani/today", s.vaniH.Today)
|
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 ---
|
// --- Auth ---
|
||||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||||
@@ -1150,52 +1184,87 @@ func (s *Server) setupRouter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
// Staff only past this point; each route then names the one
|
||||||
r.Get("/stats", s.adminH.Stats)
|
// permission it needs. A route without a permission is reachable
|
||||||
r.Get("/users", s.adminH.ListUsers)
|
// by every staff member, so only /me is left bare.
|
||||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
r.Use(middleware.RequireStaff(s.staffRepo))
|
||||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
can := func(perm string) chi.Router {
|
||||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
return r.With(middleware.RequirePermission(perm))
|
||||||
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)
|
|
||||||
|
|
||||||
// Announcements composed and published from the admin panel.
|
r.Get("/me", s.staffH.Me)
|
||||||
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("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
// Staff and roles — superadmins only.
|
||||||
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
|
||||||
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
|
||||||
r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
|
||||||
r.Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
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)
|
can("stats:read").Get("/stats", s.adminH.Stats)
|
||||||
r.Post("/broadcasts", s.broadcastH.Create)
|
|
||||||
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
can("users:read").Get("/users", s.adminH.ListUsers)
|
||||||
r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||||
r.Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||||
r.Get("/posts", s.adminH.ListPosts)
|
can("users:read").Get("/disciples", s.adminH.ListDisciples)
|
||||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||||
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
|
||||||
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
can("vani:read").Get("/vani", s.vaniH.Schedule)
|
||||||
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
can("vani:write").Post("/vani", s.vaniH.Set)
|
||||||
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
|
||||||
r.Get("/collections", s.adminH.ListCollections)
|
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
|
||||||
r.Get("/feedback", s.adminH.ListFeedback)
|
|
||||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||||
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||||
r.Get("/reports", s.adminH.ListReports)
|
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
||||||
r.Get("/short-urls", s.adminH.ListShortURLs)
|
|
||||||
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
|
||||||
|
// isn't configured (local development leaves it empty).
|
||||||
|
const defaultAdminURL = "https://admin.prabhupadasays.org"
|
||||||
|
|
||||||
|
// sendStaffWelcome tells someone they've been given admin access, and as
|
||||||
|
// what. Fire-and-forget, like the other notification emails: the grant is
|
||||||
|
// already committed, so a mail failure is logged, never surfaced.
|
||||||
|
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
|
||||||
|
if s.mail == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
|
||||||
|
s.logger.Info("staff granted; no email on file, skipping welcome",
|
||||||
|
zap.String("user_id", m.UserID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": strings.TrimSpace(m.Name),
|
||||||
|
"RoleName": m.RoleName,
|
||||||
|
"AdminURL": s.adminURL,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render staff welcome email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to := strings.TrimSpace(*m.Email)
|
||||||
|
go func() {
|
||||||
|
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
|
||||||
|
s.logger.Warn("staff welcome email send failed",
|
||||||
|
zap.String("user_id", m.UserID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// staffWelcomeEmailTpl — same styling as the other account emails. The role
|
||||||
|
// name is admin-entered (custom roles) and auto-escaped by html/template.
|
||||||
|
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
|
||||||
|
|
||||||
|
<p>Log in with your email and password at
|
||||||
|
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>For any queries, contact your co-ordinator.</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
if err := pg.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("role %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := chatUser(t, pg, roleKey)
|
||||||
|
if _, err := pg.Pool.Exec(context.Background(),
|
||||||
|
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The floor: removing the last active superadmin would leave nobody able to
|
||||||
|
// manage staff, with no way back from the UI.
|
||||||
|
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
staff := repository.NewStaffRepo(pg)
|
||||||
|
svc := NewStaffService(staff, nil, nil, "")
|
||||||
|
|
||||||
|
// Isolate from any staff already in the dev database: the floor counts
|
||||||
|
// superadmins globally. Parked in a REAL table — a TEMP table is
|
||||||
|
// per-connection, and the pool may run the cleanup on a different one,
|
||||||
|
// which would leave real staff stripped of their roles.
|
||||||
|
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
|
||||||
|
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
|
||||||
|
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
|
||||||
|
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
|
||||||
|
t.Errorf("restoring parked staff: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
|
||||||
|
})
|
||||||
|
|
||||||
|
a := makeStaff(t, pg, "superadmin")
|
||||||
|
b := makeStaff(t, pg, "superadmin")
|
||||||
|
mod := makeStaff(t, pg, "moderator")
|
||||||
|
|
||||||
|
// Nobody acts on themselves.
|
||||||
|
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
|
||||||
|
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
|
||||||
|
t.Errorf("self-block: %v, want ErrStaffSelf", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A superadmin can block another admin/mod — and another superadmin while
|
||||||
|
// one remains.
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
|
||||||
|
t.Errorf("block moderator: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
|
||||||
|
t.Errorf("block second superadmin: %v", err)
|
||||||
|
}
|
||||||
|
// Now a is the only active one: a blocked b can't be used to demote a.
|
||||||
|
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
|
||||||
|
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||||
|
}
|
||||||
|
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
|
||||||
|
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blocked staff member is turned away at the door.
|
||||||
|
acc, err := staff.Access(ctx, mod)
|
||||||
|
if err != nil || !acc.Blocked {
|
||||||
|
t.Errorf("blocked moderator access = %+v, %v", acc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom role is built only from the catalog — never the wildcard or
|
||||||
|
// admins:manage, which would make it a second superadmin.
|
||||||
|
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
|
||||||
|
|
||||||
|
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
|
||||||
|
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
|
||||||
|
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
|
||||||
|
|
||||||
|
set := rbac.NewSet(ro.Permissions)
|
||||||
|
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
|
||||||
|
t.Errorf("custom role permissions resolved wrong: %v", set.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// System roles are read-only.
|
||||||
|
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
|
||||||
|
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureMailer records sends; the welcome email goes out on a goroutine.
|
||||||
|
type captureMailer struct{ sent chan [3]string }
|
||||||
|
|
||||||
|
func (m *captureMailer) Send(to, subject, body string) error {
|
||||||
|
m.sent <- [3]string{to, subject, body}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding someone as staff emails them their role and where to log in; moving
|
||||||
|
// an existing staff member to another role does not.
|
||||||
|
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mail := &captureMailer{sent: make(chan [3]string, 4)}
|
||||||
|
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
|
||||||
|
|
||||||
|
actor := chatUser(t, pg, "Actor")
|
||||||
|
target, handle := chatUserWithHandle(t, pg, "Target")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-mail.sent:
|
||||||
|
if !strings.HasPrefix(got[0], "chatsvc-") {
|
||||||
|
t.Errorf("sent to %q, want the target's address", got[0])
|
||||||
|
}
|
||||||
|
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
|
||||||
|
if !strings.Contains(got[2], want) {
|
||||||
|
t.Errorf("email body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[1], "Moderator") {
|
||||||
|
t.Errorf("subject %q doesn't name the role", got[1])
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("no welcome email was sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-mail.sent:
|
||||||
|
t.Errorf("a role change sent an email: %q", got[1])
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user