feat: quiz and rbac
This commit is contained in:
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
|
||||
|
||||
# Frontend (CORS + email link base)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
|
||||
ADMIN_URL=
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
@@ -15,9 +17,15 @@ import (
|
||||
|
||||
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||
// the router, so every method here can assume an admin caller.
|
||||
type AdminHandler struct{ svc *service.AdminService }
|
||||
type AdminHandler struct {
|
||||
svc *service.AdminService
|
||||
// staff guards actions that would otherwise reach other staff members.
|
||||
staff *repository.StaffRepo
|
||||
}
|
||||
|
||||
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
|
||||
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
|
||||
return &AdminHandler{svc: svc, staff: staff}
|
||||
}
|
||||
|
||||
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||
func pageParams(r *http.Request) (q string, limit, offset int) {
|
||||
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
|
||||
target := chi.URLParam(r, "id")
|
||||
// A banned account loses panel access, so banning a staff member is a way
|
||||
// to remove them — which only superadmins may do. Without this, an Admin
|
||||
// holding users:ban could ban a Superadmin out of the panel.
|
||||
if _, err := h.staff.Access(r.Context(), target); err == nil {
|
||||
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
|
||||
response.Error(w, http.StatusForbidden,
|
||||
"staff accounts are managed from the Staff page by a superadmin")
|
||||
return
|
||||
}
|
||||
if target == middleware.UserID(r.Context()) {
|
||||
response.Error(w, http.StatusConflict, "you can't ban yourself")
|
||||
return
|
||||
}
|
||||
}
|
||||
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||
return
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
type staffCtxKey struct{}
|
||||
|
||||
// StaffContext is the caller's resolved admin access, set by RequireStaff.
|
||||
type StaffContext struct {
|
||||
RoleKey string
|
||||
RoleName string
|
||||
Perms rbac.Set
|
||||
}
|
||||
|
||||
// Staff returns the caller's staff context, or nil outside RequireStaff.
|
||||
func Staff(ctx context.Context) *StaffContext {
|
||||
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
|
||||
return s
|
||||
}
|
||||
|
||||
// RequireStaff admits accounts that hold an admin role and aren't blocked from
|
||||
// the panel. It MUST run after RequireAuth.
|
||||
//
|
||||
// It resolves the role's permissions once per request and puts them in
|
||||
// context, so the RequirePermission checks on each route are map lookups
|
||||
// rather than further queries. Being staff gets you through the door only;
|
||||
// every route then names the permission it needs.
|
||||
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid := UserID(r.Context())
|
||||
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := users.FindByID(r.Context(), uid)
|
||||
access, err := staff.Access(r.Context(), uid)
|
||||
if errors.Is(err, repository.ErrNotStaff) {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||
return
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
if access.Blocked {
|
||||
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
|
||||
RoleKey: access.RoleKey,
|
||||
RoleName: access.RoleName,
|
||||
Perms: rbac.NewSet(access.Permissions),
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission gates a route to staff holding perm. Must run inside
|
||||
// RequireStaff; outside it there is no staff context and everything is refused.
|
||||
func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := Staff(r.Context())
|
||||
if s == nil || !s.Perms.Has(perm) {
|
||||
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@@ -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"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
BannedAt *time.Time `json:"banned_at"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// AdminRole is the staff role key, nil for ordinary accounts.
|
||||
AdminRole *string `json:"admin_role,omitempty"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Connected auth methods. HasPassword means email+password login is set up;
|
||||
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||
@@ -56,18 +58,18 @@ type AdminDisciple struct {
|
||||
|
||||
// AdminPost is a row in the admin posts table.
|
||||
type AdminPost struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Caption string `json:"caption"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Caption string `json:"caption"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
SourceURL string `json:"source_url"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
}
|
||||
|
||||
// AdminCollection is a row in the admin collections table.
|
||||
@@ -85,18 +87,18 @@ type AdminCollection struct {
|
||||
|
||||
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||
type AdminFeedback struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
RepliedAt *time.Time `json:"replied_at"`
|
||||
ReplyMessage *string `json:"reply_message"`
|
||||
AuthorHandle *string `json:"author_handle"`
|
||||
AuthorName *string `json:"author_name"`
|
||||
AuthorEmail *string `json:"author_email"`
|
||||
ReplyMessage *string `json:"reply_message"`
|
||||
AuthorHandle *string `json:"author_handle"`
|
||||
AuthorName *string `json:"author_name"`
|
||||
AuthorEmail *string `json:"author_email"`
|
||||
}
|
||||
|
||||
// AdminReport is a unified projection over the three report tables
|
||||
|
||||
@@ -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,
|
||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||
u.disciple_id,
|
||||
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users u
|
||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||
&u.HasPassword, &u.Providers,
|
||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
||||
&u.DiscipleID, &total); err != nil {
|
||||
&u.DiscipleID, &u.AdminRole, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||
}
|
||||
// Resolve plan default + overrides so the panel shows what's in force.
|
||||
|
||||
@@ -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
|
||||
// would buy sub-ms wins for added complexity.
|
||||
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
||||
s := &StatsSnapshot{}
|
||||
// Non-nil so an empty table serialises as [] rather than null.
|
||||
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
|
||||
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
scalars := []struct {
|
||||
|
||||
+114
-45
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
@@ -78,6 +79,9 @@ type Server struct {
|
||||
statsH *handler.StatsHandler
|
||||
adminH *handler.AdminHandler
|
||||
broadcastH *handler.BroadcastHandler
|
||||
staffRepo *repository.StaffRepo
|
||||
staffH *handler.StaffHandler
|
||||
quizH *handler.QuizHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
authIssuer *auth.Issuer
|
||||
@@ -224,6 +228,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
||||
|
||||
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
|
||||
// target title and kind.
|
||||
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
|
||||
|
||||
// OAuth — wired only when at least one provider is configured. The
|
||||
// registry skips providers with missing creds, so partial configs (e.g.
|
||||
// Google only, no Facebook) work out of the box.
|
||||
@@ -344,7 +352,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
// Gated by RequireAuth + RequireAdmin in the router.
|
||||
adminRepo := repository.NewAdminRepo(s.pg)
|
||||
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||
// Admin RBAC: who is staff, with which permissions.
|
||||
s.staffRepo = repository.NewStaffRepo(s.pg)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
|
||||
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
|
||||
|
||||
// Broadcasts — admin announcements shown once per user. No notification
|
||||
// rows: the modal is the surface, and push plus the realtime nudge only
|
||||
@@ -479,8 +490,14 @@ func (s *Server) setupRouter() {
|
||||
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||
// separate origin). Empty origins are filtered so the list never widens
|
||||
// unintentionally.
|
||||
adminURL := s.cfg.AdminURL
|
||||
if adminURL == "" && s.cfg.Env == "development" {
|
||||
// The admin app's dev server (`next dev -p 3001`), so local admin
|
||||
// work needs no extra config. Production must set ADMIN_URL.
|
||||
adminURL = "http://localhost:3001"
|
||||
}
|
||||
allowedOrigins := []string{}
|
||||
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
||||
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
|
||||
if o != "" {
|
||||
allowedOrigins = append(allowedOrigins, o)
|
||||
}
|
||||
@@ -708,6 +725,23 @@ func (s *Server) setupRouter() {
|
||||
// Vāṇī of the Day — public daily content pick for the home page.
|
||||
r.Get("/vani/today", s.vaniH.Today)
|
||||
|
||||
// Quizzes. Browsing is open to signed-out readers (they see that a
|
||||
// quiz exists); taking one, and seeing results, needs an account.
|
||||
r.Route("/quizzes", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/", s.quizH.List)
|
||||
r.Get("/for", s.quizH.ForPath)
|
||||
r.Get("/{id}", s.quizH.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/{id}/start", s.quizH.Start)
|
||||
r.Post("/{id}/answer", s.quizH.Answer)
|
||||
r.Get("/{id}/result", s.quizH.Result)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Auth ---
|
||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||
@@ -1150,52 +1184,87 @@ func (s *Server) setupRouter() {
|
||||
return
|
||||
}
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||
r.Get("/stats", s.adminH.Stats)
|
||||
r.Get("/users", s.adminH.ListUsers)
|
||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
// Direct-disciple linking: mark an account as one of Śrīla
|
||||
// Prabhupāda's initiated disciples (or clear the mark).
|
||||
r.Get("/disciples", s.adminH.ListDisciples)
|
||||
// Staff only past this point; each route then names the one
|
||||
// permission it needs. A route without a permission is reachable
|
||||
// by every staff member, so only /me is left bare.
|
||||
r.Use(middleware.RequireStaff(s.staffRepo))
|
||||
can := func(perm string) chi.Router {
|
||||
return r.With(middleware.RequirePermission(perm))
|
||||
}
|
||||
|
||||
// Announcements composed and published from the admin panel.
|
||||
r.Get("/vani", s.vaniH.Schedule)
|
||||
r.Post("/vani", s.vaniH.Set)
|
||||
r.Post("/vani/by-url", s.vaniH.SetByURL)
|
||||
r.Delete("/vani/{day}", s.vaniH.Clear)
|
||||
r.Get("/me", s.staffH.Me)
|
||||
|
||||
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||
r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||
r.Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
||||
// Staff and roles — superadmins only.
|
||||
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
|
||||
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
|
||||
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
|
||||
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
|
||||
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
|
||||
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
|
||||
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
|
||||
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
|
||||
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
|
||||
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
|
||||
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
|
||||
|
||||
r.Get("/broadcasts", s.broadcastH.List)
|
||||
r.Post("/broadcasts", s.broadcastH.Create)
|
||||
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
||||
r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
||||
r.Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
||||
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||
r.Get("/posts", s.adminH.ListPosts)
|
||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
||||
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||
r.Get("/collections", s.adminH.ListCollections)
|
||||
r.Get("/feedback", s.adminH.ListFeedback)
|
||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||
r.Get("/reports", s.adminH.ListReports)
|
||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
r.Get("/short-urls", s.adminH.ListShortURLs)
|
||||
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||
can("stats:read").Get("/stats", s.adminH.Stats)
|
||||
|
||||
can("users:read").Get("/users", s.adminH.ListUsers)
|
||||
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
can("users:read").Get("/disciples", s.adminH.ListDisciples)
|
||||
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||
|
||||
can("vani:read").Get("/vani", s.vaniH.Schedule)
|
||||
can("vani:write").Post("/vani", s.vaniH.Set)
|
||||
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
|
||||
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
|
||||
|
||||
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
||||
|
||||
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
|
||||
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
|
||||
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
||||
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
||||
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
||||
|
||||
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
|
||||
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
|
||||
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||
can("collections:read").Get("/collections", s.adminH.ListCollections)
|
||||
|
||||
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
|
||||
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||
|
||||
can("reports:read").Get("/reports", s.adminH.ListReports)
|
||||
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
|
||||
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
|
||||
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
|
||||
can("quizzes:write").Post("/quizzes", s.quizH.Create)
|
||||
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
|
||||
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
|
||||
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
|
||||
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
|
||||
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
||||
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
||||
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
||||
|
||||
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
||||
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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