14 Commits
Author SHA1 Message Date
Krispire AdminandClaude Opus 5.5 d76505772a Admin-managed feedback categories
Categories move from a hard-coded list to a feedback_categories table
(migration 0062), seeded with the four existing keys, labels and
descriptions so production feedback is untouched; any other key already in
use gets an archived category. Keys are fixed, so renames relabel past
feedback. Archive hides a category from the form; only unused categories
can be deleted. Submissions are validated against active categories, and
feedback responses carry category_label.

New: GET /feedback/categories (public); /admin/feedback/categories CRUD +
reorder behind the new feedback:categories permission (granted to Admin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30
Krispire Admin fa0255e9cd quiz grouping 2026-10-18 13:02:13 +05:30
Krispire Admin eb02d08f14 unique quiz constraint 2026-10-16 15:23:06 +05:30
Krispire Admin b1f78b1655 quiz resetc 2026-10-15 20:59:53 +05:30
Krispire Admin 4b38047c26 feat: quiz and rbac 2026-10-15 19:43:17 +05:30
Krispire Admin 7d7ba2a002 feedback formatter 2026-10-09 06:40:13 +05:30
Krispire Admin 6633d0cca3 forward chat msg fix 2026-09-02 23:00:55 +05:30
Krispire Admin 0fee56d272 feat: vani of the day edit 2026-08-31 13:39:56 +05:30
Krispire Admin 44fd1d69ab fix: hide suspended account 2026-08-31 12:28:25 +05:30
Krispire Admin ac92bc2cf3 feat: chat notification 2026-08-30 11:13:33 +05:30
Krispire Admin a2d22ed829 feat: add stickers delete api 2026-08-30 10:31:37 +05:30
Krispire Admin 4427849647 feat: custom stickers apis 2026-08-30 03:02:49 +05:30
Krispire Admin fecb9bc045 feat: add stickers 2026-08-30 02:18:53 +05:30
Krispire Admin cee2d3c874 feat: chat reactions 2026-08-30 01:51:32 +05:30
69 changed files with 6808 additions and 188 deletions
+2
View File
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
# Frontend (CORS + email link base) # Frontend (CORS + email link base)
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
ADMIN_URL=
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=
+26 -3
View File
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
@@ -15,9 +17,15 @@ import (
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in // AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
// the router, so every method here can assume an admin caller. // the router, so every method here can assume an admin caller.
type AdminHandler struct{ svc *service.AdminService } type AdminHandler struct {
svc *service.AdminService
// staff guards actions that would otherwise reach other staff members.
staff *repository.StaffRepo
}
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} } func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
return &AdminHandler{svc: svc, staff: staff}
}
// pageParams pulls (q, limit, offset) from the query string with sane defaults. // pageParams pulls (q, limit, offset) from the query string with sane defaults.
func pageParams(r *http.Request) (q string, limit, offset int) { func pageParams(r *http.Request) (q string, limit, offset int) {
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
} }
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) { func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned) target := chi.URLParam(r, "id")
// A banned account loses panel access, so banning a staff member is a way
// to remove them — which only superadmins may do. Without this, an Admin
// holding users:ban could ban a Superadmin out of the panel.
if _, err := h.staff.Access(r.Context(), target); err == nil {
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
response.Error(w, http.StatusForbidden,
"staff accounts are managed from the Staff page by a superadmin")
return
}
if target == middleware.UserID(r.Context()) {
response.Error(w, http.StatusConflict, "you can't ban yourself")
return
}
}
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
if err != nil { if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update account") response.Error(w, http.StatusInternalServerError, "could not update account")
return return
+111
View File
@@ -38,6 +38,10 @@ func writeChatError(w http.ResponseWriter, err error) {
response.Error(w, http.StatusNotFound, err.Error()) response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrChatBadEntity): case errors.Is(err, service.ErrChatBadEntity):
response.Error(w, http.StatusBadRequest, err.Error()) response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrStickerNotOwned):
response.Error(w, http.StatusForbidden, "that sticker isn't in your collection")
case errors.Is(err, service.ErrChatBadEmoji):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadQuote): case errors.Is(err, service.ErrChatBadQuote):
response.Error(w, http.StatusBadRequest, err.Error()) response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadAvatarKey): case errors.Is(err, service.ErrChatBadAvatarKey):
@@ -204,6 +208,8 @@ type sendReq struct {
// quote is a passage shared from the library. `path` must be app-relative; // quote is a passage shared from the library. `path` must be app-relative;
// the service refuses anything that could navigate off-site. // the service refuses anything that could navigate off-site.
Quote *quoteReq `json:"quote"` Quote *quoteReq `json:"quote"`
// sticker_key sends reward artwork. Ownership is checked server-side.
StickerKey *string `json:"sticker_key"`
// entity shares something else in the app — a post — as a card. Only the // entity shares something else in the app — a post — as a card. Only the
// reference; the BFF resolves the contents and re-checks visibility. // reference; the BFF resolves the contents and re-checks visibility.
Entity *entityReq `json:"entity"` Entity *entityReq `json:"entity"`
@@ -247,6 +253,7 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
ReplyToID: req.ReplyToID, ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL, PreviewURL: req.PreviewURL,
Quote: quote, Quote: quote,
StickerKey: req.StickerKey,
Entity: entity, Entity: entity,
Attachments: media, Attachments: media,
}) })
@@ -648,6 +655,110 @@ func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until}) response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
} }
// Message — GET /chat/conversations/{id}/messages/{messageID}
func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) {
msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msg)
}
// MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions
//
// Only read when someone opens the list; the thread itself renders from the
// grouped counts that ride along with each message.
func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.MessageReactions(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
// Stickers — GET /chat/stickers. What the caller may send.
func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type saveStickerReq struct {
Key string `json:"key"`
}
// SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker
//
// Keyed to the message it was sent on, not just the sticker key: that is what
// proves the caller was actually sent it rather than guessing at the catalog.
func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) {
var req saveStickerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Key); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Typing — POST /chat/conversations/{id}/typing
//
// Fire-and-forget: nothing is stored and the response carries nothing, because
// a typing indicator is stale within seconds of being sent.
func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Typing(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type reactReq struct {
Emoji string `json:"emoji"`
}
// React — PUT /chat/conversations/{id}/messages/{messageID}/reaction.
// Replaces any previous choice.
func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) {
var req reactReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// The message id is authoritative: React re-derives the conversation from
// it and authorizes there, so the id in the path can't be used to react
// into a thread the caller isn't in.
if err := h.svc.React(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Emoji); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction.
// Idempotent.
func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Receipts — GET /chat/conversations/{id}/receipts // Receipts — GET /chat/conversations/{id}/receipts
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) { func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")) rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
+114
View File
@@ -0,0 +1,114 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// FeedbackCategoryHandler serves the /feedback form's categories: the
// public list, and their management in the admin panel.
type FeedbackCategoryHandler struct {
svc *service.FeedbackCategoryService
}
func NewFeedbackCategoryHandler(svc *service.FeedbackCategoryService) *FeedbackCategoryHandler {
return &FeedbackCategoryHandler{svc: svc}
}
func writeCategoryError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrFeedbackCategoryNotFound):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, repository.ErrFeedbackCategoryInUse),
errors.Is(err, repository.ErrFeedbackCategoryExists),
errors.Is(err, service.ErrCategoryLastActive):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrCategoryLabel), errors.Is(err, service.ErrCategoryDescription):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "feedback category request failed")
}
}
// Public — GET /feedback/categories: the form's options, in order.
func (h *FeedbackCategoryHandler) Public(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Active(r.Context())
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
// List — GET /admin/feedback/categories: all, with usage counts.
func (h *FeedbackCategoryHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.All(r.Context())
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type categoryReq struct {
Label string `json:"label"`
Description string `json:"description"`
Archived bool `json:"archived"`
}
func (h *FeedbackCategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
var req categoryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
c, err := h.svc.Create(r.Context(), req.Label, req.Description)
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
func (h *FeedbackCategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
var req categoryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.svc.Update(r.Context(), chi.URLParam(r, "key"), req.Label, req.Description, req.Archived); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *FeedbackCategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "key")); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Reorder — PUT /admin/feedback/categories/order {"keys": [...]}.
func (h *FeedbackCategoryHandler) Reorder(w http.ResponseWriter, r *http.Request) {
var req struct {
Keys []string `json:"keys"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Keys) == 0 {
response.Error(w, http.StatusBadRequest, "keys are required")
return
}
if err := h.svc.Reorder(r.Context(), req.Keys); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+259
View File
@@ -0,0 +1,259 @@
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), errors.Is(err, repository.ErrQuizTargetTaken):
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)
}
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
// that person's attempt so the quiz is pending for them again.
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
writeQuizError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Users ─────────────────────────────────────────────────────────────────
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
// page with the caller's progress, or null when the page has none.
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
if err != nil {
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)
}
+108
View File
@@ -2,11 +2,15 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
@@ -114,6 +118,110 @@ func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) {
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the // AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
// client has shown the confetti, so a gift surfaces exactly once. // client has shown the confetti, so a gift surfaces exactly once.
// OpenGift — POST /sadhna/rewards/gifts/{key}/open
//
// Called after the celebration is dismissed. Idempotent: opening an already
// opened gift changes nothing.
func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.OpenGift(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil {
response.Error(w, http.StatusInternalServerError, "could not open that gift")
return
}
response.NoContent(w)
}
// GiftableRewards — GET /admin/reward-gifts/catalog
func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.GiftableRewards(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load the catalog")
return
}
response.JSON(w, http.StatusOK, list)
}
// DeleteCustomGift — DELETE /admin/reward-gifts/{key}
func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such custom gift")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.NoContent(w)
}
type customGiftReq struct {
Name string `json:"name"`
Meaning string `json:"meaning"`
ImageURL string `json:"image_url"`
}
// CreateCustomGift — POST /admin/reward-gifts/catalog
//
// Adds a sticker to the catalog. It reaches nobody until it is sent.
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
var req customGiftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusCreated, reward)
}
// PresignGiftImage — POST /admin/reward-gifts/image-presign
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
var req struct {
ContentType string `json:"content_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload, "image_url": public,
})
}
type giftReq struct {
RewardKey string `json:"reward_key"`
}
// GiftToEveryone — POST /admin/reward-gifts
//
// Hands one reward to every account, unopened. Idempotent, so a repeated
// click tops up only the people who don't have it rather than duplicating.
func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) {
var req giftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" {
response.Error(w, http.StatusBadRequest, "reward_key is required")
return
}
n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey)
if err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such reward")
return
}
response.Error(w, http.StatusInternalServerError, "could not send the gift")
return
}
response.JSON(w, http.StatusOK, map[string]int64{"granted": n})
}
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) { func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context()) uid := middleware.UserID(r.Context())
if err := h.svc.AckRewards(r.Context(), uid); err != nil { if err := h.svc.AckRewards(r.Context(), uid); err != nil {
+6
View File
@@ -83,6 +83,11 @@ const searchStopwordsSet = "common"
// searchResultItem represents a single search result in the API response. // searchResultItem represents a single search result in the API response.
type searchResultItem struct { type searchResultItem struct {
Collection string `json:"collection"` Collection string `json:"collection"`
// DocID is the underlying Mongo _id hex — the indexer uses it as the
// Typesense document id. Surfaced so the admin panel can pin a specific
// item as the Vāṇī of the Day; it identifies public content, not anything
// private.
DocID string `json:"doc_id,omitempty"`
Slug string `json:"slug,omitempty"` Slug string `json:"slug,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
item := searchResultItem{ item := searchResultItem{
Collection: collection, Collection: collection,
DocID: getStringField(doc, "id"),
URL: getStringField(doc, "url"), URL: getStringField(doc, "url"),
score: score, score: score,
} }
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
type StaffHandler struct {
svc *service.StaffService
}
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
func writeStaffError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
errors.Is(err, repository.ErrUserGone):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
errors.Is(err, repository.ErrRoleKeyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
errors.Is(err, service.ErrRoleNoPermissions):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "staff request failed")
}
}
// Me — GET /admin/me. The caller's role and effective permissions, which the
// panel uses to hide what they can't do. Hiding is a courtesy: every route
// enforces its own permission regardless.
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
s := middleware.Staff(r.Context())
response.JSON(w, http.StatusOK, service.Me{
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
})
}
// Catalog — GET /admin/permissions. What a custom role may be built from.
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
response.JSON(w, http.StatusOK, rbac.Catalog)
}
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.List(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type grantReq struct {
Handle string `json:"handle"`
RoleID string `json:"role_id"`
}
// Grant — POST /admin/staff
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
var req grantReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
return
}
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// ChangeRole — PATCH /admin/staff/{id}
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
var req struct {
RoleID string `json:"role_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "role_id is required")
return
}
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Revoke — DELETE /admin/staff/{id}
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
roles, err := h.svc.Roles(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, roles)
}
type adminRoleReq struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusCreated, ro)
}
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, ro)
}
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
+126
View File
@@ -1,8 +1,15 @@
package handler package handler
import ( import (
"encoding/json"
"errors"
"net/http" "net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
@@ -18,6 +25,125 @@ func NewVaniHandler(svc *service.VaniService) *VaniHandler {
// Today — GET /vani/today. Public; the same pick is returned to everyone for // Today — GET /vani/today. Public; the same pick is returned to everyone for
// the current UTC day. Responds with `data: null` if there's no content. // the current UTC day. Responds with `data: null` if there's no content.
type setVaniReq struct {
// Day as YYYY-MM-DD (UTC). Empty means today.
Day string `json:"day"`
Kind string `json:"kind"`
// RefID is the content's Mongo _id hex, which search returns as doc_id.
RefID string `json:"ref_id"`
}
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
var req setVaniReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
type setVaniByURLReq struct {
Day string `json:"day"`
URL string `json:"url"`
}
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
var req setVaniByURLReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
day, err := parseVaniDay(chi.URLParam(r, "day"))
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
if err := h.svc.ClearForDay(r.Context(), day); err != nil {
writeVaniError(w, err)
return
}
response.NoContent(w)
}
// Schedule — GET /admin/vani. Decided days from today onward.
func (h *VaniHandler) Schedule(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
picks, err := h.svc.Schedule(r.Context(), limit)
if err != nil {
writeVaniError(w, err)
return
}
out := make([]map[string]any, 0, len(picks))
for _, p := range picks {
out = append(out, map[string]any{
"day": p.Day.Format("2006-01-02"),
"kind": p.Kind,
"ref_id": p.RefID,
"payload": p.Payload,
})
}
response.JSON(w, http.StatusOK, out)
}
// parseVaniDay accepts YYYY-MM-DD, or empty for today (UTC — the pick is
// keyed on a UTC calendar day so everyone sees the same item).
func parseVaniDay(s string) (time.Time, error) {
if strings.TrimSpace(s) == "" {
return time.Now().UTC().Truncate(24 * time.Hour), nil
}
return time.Parse("2006-01-02", strings.TrimSpace(s))
}
func writeVaniError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrVaniRefInvalid),
errors.Is(err, service.ErrVaniBadKind),
errors.Is(err, service.ErrVaniBadURL):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrVaniRefNotFound):
response.Error(w, http.StatusNotFound, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "vāṇī request failed")
}
}
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) { func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
payload, err := h.svc.Today(r.Context()) payload, err := h.svc.Today(r.Context())
if err != nil { if err != nil {
+53 -8
View File
@@ -1,17 +1,38 @@
package middleware package middleware
import ( import (
"context"
"errors"
"net/http" "net/http"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
// RequireAdmin gates a route to users whose role is "admin". It MUST run after type staffCtxKey struct{}
// RequireAuth (it reads the authenticated user id from context, then looks up
// the row to check the role). Non-admins get 403; this keeps the whole // StaffContext is the caller's resolved admin access, set by RequireStaff.
// /api/v1/admin/* surface invisible to ordinary accounts. type StaffContext struct {
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler { RoleKey string
RoleName string
Perms rbac.Set
}
// Staff returns the caller's staff context, or nil outside RequireStaff.
func Staff(ctx context.Context) *StaffContext {
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
return s
}
// RequireStaff admits accounts that hold an admin role and aren't blocked from
// the panel. It MUST run after RequireAuth.
//
// It resolves the role's permissions once per request and puts them in
// context, so the RequirePermission checks on each route are map lookups
// rather than further queries. Being staff gets you through the door only;
// every route then names the permission it needs.
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := UserID(r.Context()) uid := UserID(r.Context())
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
response.Error(w, http.StatusUnauthorized, "not authenticated") response.Error(w, http.StatusUnauthorized, "not authenticated")
return return
} }
u, err := users.FindByID(r.Context(), uid) access, err := staff.Access(r.Context(), uid)
if errors.Is(err, repository.ErrNotStaff) {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
if err != nil { if err != nil {
response.Error(w, http.StatusInternalServerError, "auth check failed") response.Error(w, http.StatusInternalServerError, "auth check failed")
return return
} }
if u.Role != "admin" { if access.Blocked {
response.Error(w, http.StatusForbidden, "admin access required") response.Error(w, http.StatusForbidden, "your admin access has been suspended")
return
}
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
RoleKey: access.RoleKey,
RoleName: access.RoleName,
Perms: rbac.NewSet(access.Permissions),
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequirePermission gates a route to staff holding perm. Must run inside
// RequireStaff; outside it there is no staff context and everything is refused.
func RequirePermission(perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := Staff(r.Context())
if s == nil || !s.Perms.Has(perm) {
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
+47
View File
@@ -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) }
+3
View File
@@ -17,6 +17,8 @@ type AdminUser struct {
Plan string `json:"plan"` Plan string `json:"plan"`
EmailVerifiedAt *time.Time `json:"email_verified_at"` EmailVerifiedAt *time.Time `json:"email_verified_at"`
BannedAt *time.Time `json:"banned_at"` BannedAt *time.Time `json:"banned_at"`
// AdminRole is the staff role key, nil for ordinary accounts.
AdminRole *string `json:"admin_role,omitempty"`
PostCount int `json:"post_count"` PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -87,6 +89,7 @@ type AdminCollection struct {
type AdminFeedback struct { type AdminFeedback struct {
ID string `json:"id"` ID string `json:"id"`
Category string `json:"category"` Category string `json:"category"`
CategoryLabel string `json:"category_label"`
Message string `json:"message"` Message string `json:"message"`
ContactEmail *string `json:"contact_email"` ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+38
View File
@@ -171,11 +171,16 @@ const (
// app-relative deep link back to the phrase, title the source reference, // app-relative deep link back to the phrase, title the source reference,
// description the passage itself. // description the passage itself.
AttachmentQuote = "quote" AttachmentQuote = "quote"
// AttachmentSticker is reward artwork: image_url and title carry the art
// and its name, entity_id the reward key a recipient can save by.
AttachmentSticker = "sticker"
) )
// Entity types a message can reference. // Entity types a message can reference.
const ( const (
EntityPost = "post" EntityPost = "post"
// EntitySticker marks a sticker attachment's entity_id as a reward key.
EntitySticker = "sticker"
) )
// Attachment is a media file, link preview or shared entity on a message. // Attachment is a media file, link preview or shared entity on a message.
@@ -252,6 +257,39 @@ type Message struct {
// Attachments is always present (possibly empty) so clients need no nil // Attachments is always present (possibly empty) so clients need no nil
// check before iterating. // check before iterating.
Attachments []Attachment `json:"attachments"` Attachments []Attachment `json:"attachments"`
// Reactions is always present (possibly empty), same contract as
// Attachments — clients render the chip row without a nil check.
Reactions []Reaction `json:"reactions"`
}
// Sticker is one piece of reward artwork, as it appears in the picker and on
// a message.
type Sticker struct {
Key string `json:"key"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
// Saved is true when it came from a chat rather than from sādhana, so the
// picker can say where it came from.
Saved bool `json:"saved"`
}
// ReactionDetail is one person's reaction, for the "who reacted" list.
type ReactionDetail struct {
User ChatPeer `json:"user"`
Emoji string `json:"emoji"`
CreatedAt time.Time `json:"created_at"`
}
// Reaction is one emoji grouped across the people who chose it.
//
// Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the
// full membership of every reaction would grow with the room.
type Reaction struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
// Mine marks the viewer's own choice, so the chip can be highlighted and
// tapping it toggles off.
Mine bool `json:"mine"`
} }
// Receipts are the conversation-wide delivery cursors used to draw ticks on // Receipts are the conversation-wide delivery cursors used to draw ticks on
+15
View File
@@ -18,6 +18,9 @@ type Feedback struct {
ID string `json:"id"` ID string `json:"id"`
UserID *string `json:"user_id,omitempty"` UserID *string `json:"user_id,omitempty"`
Category string `json:"category"` Category string `json:"category"`
// CategoryLabel is the category's current display name (falls back to
// the key if the category was ever removed).
CategoryLabel string `json:"category_label"`
Message string `json:"message"` Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"` ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -50,3 +53,15 @@ type Feedback struct {
AuthorName *string `json:"author_name,omitempty"` AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised AuthorEmail *string `json:"-"` // server-side only; never serialised
} }
// FeedbackCategory is one option on the /feedback form, managed from the
// admin panel. Key is what feedback.category stores and never changes.
type FeedbackCategory struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
SortOrder int `json:"sort_order"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// UsageCount is admin-only: how much feedback is filed under it.
UsageCount int `json:"usage_count,omitempty"`
}
+137
View File
@@ -0,0 +1,137 @@
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"`
// TargetYear is the lecture or conversation's year, for grouping.
TargetYear *int `json:"target_year,omitempty"`
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"`
}
+8 -1
View File
@@ -52,9 +52,16 @@ type SadhnaReward struct {
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a // SadhnaRewardGrant is a reward a user earned at a streak milestone (a
// multiple of 7 days). The full reward is embedded for direct rendering. // multiple of 7 days). The full reward is embedded for direct rendering.
type SadhnaRewardGrant struct { type SadhnaRewardGrant struct {
Milestone int `json:"milestone"` // Milestone is the streak day this was earned on. Nil for an admin gift,
// which isn't tied to a streak at all.
Milestone *int `json:"milestone"`
GrantedAt time.Time `json:"granted_at"` GrantedAt time.Time `json:"granted_at"`
Reward SadhnaReward `json:"reward"` Reward SadhnaReward `json:"reward"`
// Source is "streak" or "gift".
Source string `json:"source"`
// Opened is false while a gift is still wrapped — the gallery renders
// those blurred until the devotee opens them.
Opened bool `json:"opened"`
} }
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It // SadhnaDashboard is the single payload the /sadhna-streak page reads. It
+37
View File
@@ -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
}
+110
View File
@@ -0,0 +1,110 @@
// 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"},
{"feedback:categories", "Feedback", "Manage feedback categories"},
{"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"},
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
{"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
}
+5 -3
View File
@@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers, FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit, u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
u.disciple_id, u.disciple_id,
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
COUNT(*) OVER() AS total COUNT(*) OVER() AS total
FROM users u FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%' WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers, &u.HasPassword, &u.Providers,
&bookmarkLimit, &publicCollLimit, &privateCollLimit, &bookmarkLimit, &publicCollLimit, &privateCollLimit,
&u.DiscipleID, &total); err != nil { &u.DiscipleID, &u.AdminRole, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err) return nil, 0, fmt.Errorf("scan admin user: %w", err)
} }
// Resolve plan default + overrides so the panel shows what's in force. // Resolve plan default + overrides so the panel shows what's in force.
@@ -247,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
status = "all" status = "all"
} }
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.category, f.message, f.contact_email, f.created_at, SELECT f.id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message, f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
u.handle, u.name, u.email, u.handle, u.name, u.email,
COUNT(*) OVER() AS total COUNT(*) OVER() AS total
FROM feedback f FROM feedback f
LEFT JOIN users u ON u.id = f.user_id LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE ($1 = 'all' WHERE ($1 = 'all'
OR ($1 = 'open' AND f.resolved_at IS NULL) OR ($1 = 'open' AND f.resolved_at IS NULL)
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL)) OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
@@ -267,7 +269,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
total := 0 total := 0
for rows.Next() { for rows.Next() {
var f model.AdminFeedback var f model.AdminFeedback
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, if err := rows.Scan(&f.ID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage, &f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil { &f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin feedback: %w", err) return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
+267 -2
View File
@@ -23,6 +23,9 @@ var (
ErrAttachmentGone = errors.New("attachment not found") ErrAttachmentGone = errors.New("attachment not found")
ErrMessageNotHere = errors.New("message is not in this conversation") ErrMessageNotHere = errors.New("message is not in this conversation")
ErrUserGone = errors.New("user not found") ErrUserGone = errors.New("user not found")
// ErrStickerNotOwned covers both "no such sticker" and "not yours" on
// purpose: the catalog shouldn't be enumerable by probing keys.
ErrStickerNotOwned = errors.New("sticker not available")
) )
type ChatRepo struct{ pg *Postgres } type ChatRepo struct{ pg *Postgres }
@@ -336,8 +339,10 @@ func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, lim
// Messages returns one page of history, newest first. beforeSeq == 0 starts at // Messages returns one page of history, newest first. beforeSeq == 0 starts at
// the newest message; otherwise it is an exclusive upper bound, which makes // the newest message; otherwise it is an exclusive upper bound, which makes
// paging stable even as new messages arrive at the top. // paging stable even as new messages arrive at the top.
func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) { // messageSelectFrom is the canonical read shape for a message, including the
rows, err := r.pg.Pool.Query(ctx, ` // quoted-reply preview. Shared by the paged thread read and the single-message
// read so the two can never drift into returning different shapes.
const messageSelectFrom = `
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at, m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at,
rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '') rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '')
@@ -351,12 +356,41 @@ func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string,
-- thread's text through a quote. -- thread's text through a quote.
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
) rep ON TRUE ) rep ON TRUE
`
// Message reads one message of a conversation, in the same shape as the paged
// read. Hidden (deleted-for-me) messages are excluded, so a refresh can't
// resurrect something the caller removed from their own view.
func (r *ChatRepo) Message(ctx context.Context, conversationID, messageID, userID string) (*model.Message, error) {
msgs, err := r.scanMessages(ctx, messageSelectFrom+`
WHERE m.conversation_id = $1
AND m.id = $2
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = m.id AND h.user_id = $3)`,
conversationID, messageID, userID)
if err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, ErrMessageNotHere
}
return &msgs[0], nil
}
func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) {
return r.scanMessages(ctx, messageSelectFrom+`
WHERE m.conversation_id = $1 WHERE m.conversation_id = $1
AND ($2 = 0 OR m.seq < $2) AND ($2 = 0 OR m.seq < $2)
AND NOT EXISTS (SELECT 1 FROM message_hides h AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = m.id AND h.user_id = $3) WHERE h.message_id = m.id AND h.user_id = $3)
ORDER BY m.seq DESC ORDER BY m.seq DESC
LIMIT $4`, conversationID, beforeSeq, userID, limit) LIMIT $4`, conversationID, beforeSeq, userID, limit)
}
// scanMessages runs a query built on messageSelectFrom and decodes its rows.
// Shared so the paged read and the single-message read decode identically.
func (r *ChatRepo) scanMessages(ctx context.Context, query string, args ...any) ([]model.Message, error) {
rows, err := r.pg.Pool.Query(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("query messages: %w", err) return nil, fmt.Errorf("query messages: %w", err)
} }
@@ -1157,6 +1191,237 @@ func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, con
return nil return nil
} }
// SetReaction records one person's emoji on a message, replacing whatever
// they chose before. Returns the message's conversation so the caller can
// authorize and publish without a second query.
func (r *ChatRepo) SetReaction(ctx context.Context, messageID, userID, emoji string) (conversationID string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
INSERT INTO message_reactions (message_id, user_id, conversation_id, emoji)
SELECT $1, $2, m.conversation_id, $3
FROM messages m
WHERE m.id = $1 AND m.deleted_at IS NULL
ON CONFLICT (message_id, user_id)
DO UPDATE SET emoji = EXCLUDED.emoji, updated_at = now()
RETURNING conversation_id`, messageID, userID, emoji).Scan(&conversationID)
if errors.Is(err, pgx.ErrNoRows) {
// No row inserted means no such message, or it was deleted — you
// cannot react to something that isn't there.
return "", ErrMessageNotHere
}
if err != nil {
return "", fmt.Errorf("set reaction: %w", err)
}
return conversationID, nil
}
// ClearReaction removes this user's reaction. Returns the conversation so the
// caller can publish the change; ErrMessageNotHere when there was none.
func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string) (conversationID string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
DELETE FROM message_reactions
WHERE message_id = $1 AND user_id = $2
RETURNING conversation_id`, messageID, userID).Scan(&conversationID)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrMessageNotHere
}
if err != nil {
return "", fmt.Errorf("clear reaction: %w", err)
}
return conversationID, nil
}
// Stickers lists everything a user may send: rewards they earned through
// sādhana, plus stickers they saved from a chat.
//
// One UNION rather than two calls and a merge in Go — "may I send this?" and
// "what can I send?" then have exactly one answer, and it stays correct if a
// user both earns and is sent the same sticker.
func (r *ChatRepo) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT sr.key, sr.name, sr.image_url, bool_and(src.saved) AS saved
FROM (
SELECT reward_id, FALSE AS saved FROM sadhna_reward_grants WHERE user_id = $1
UNION ALL
SELECT reward_id, TRUE AS saved FROM saved_stickers WHERE user_id = $1
) src
JOIN sadhna_rewards sr ON sr.id = src.reward_id
WHERE sr.active
GROUP BY sr.key, sr.name, sr.image_url, sr.sort
ORDER BY sr.sort, sr.name`, userID)
if err != nil {
return nil, fmt.Errorf("stickers: %w", err)
}
defer rows.Close()
out := []model.Sticker{}
for rows.Next() {
var s model.Sticker
if err := rows.Scan(&s.Key, &s.Name, &s.ImageURL, &s.Saved); err != nil {
return nil, fmt.Errorf("scan sticker: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// Sticker looks up one piece of artwork a user is entitled to send.
// ErrStickerNotOwned when they have neither earned nor saved it.
func (r *ChatRepo) Sticker(ctx context.Context, userID, key string) (*model.Sticker, error) {
var s model.Sticker
err := r.pg.Pool.QueryRow(ctx, `
SELECT sr.key, sr.name, sr.image_url
FROM sadhna_rewards sr
WHERE sr.key = $2 AND sr.active
AND (EXISTS (SELECT 1 FROM sadhna_reward_grants g
WHERE g.user_id = $1 AND g.reward_id = sr.id)
OR EXISTS (SELECT 1 FROM saved_stickers ss
WHERE ss.user_id = $1 AND ss.reward_id = sr.id))`,
userID, key).Scan(&s.Key, &s.Name, &s.ImageURL)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrStickerNotOwned
}
if err != nil {
return nil, fmt.Errorf("sticker: %w", err)
}
return &s, nil
}
// SaveSticker keeps a sticker someone sent. Idempotent.
//
// Only stickers that actually appear on a message in a thread the caller is
// in can be saved — otherwise knowing a key would be enough to collect the
// whole catalog without earning any of it.
func (r *ChatRepo) SaveSticker(ctx context.Context, userID, key, fromMessageID string) error {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO saved_stickers (user_id, reward_id, saved_from)
SELECT $1, sr.id, m.sender_id
FROM message_attachments a
JOIN messages m ON m.id = a.message_id
JOIN conversation_participants p
ON p.conversation_id = m.conversation_id AND p.user_id = $1 AND p.left_at IS NULL
JOIN sadhna_rewards sr ON sr.key = a.entity_id AND sr.active
WHERE a.message_id = $3 AND a.kind = 'sticker' AND a.entity_id = $2
ON CONFLICT (user_id, reward_id) DO NOTHING`,
userID, key, fromMessageID)
if err != nil {
return fmt.Errorf("save sticker: %w", err)
}
// Zero rows means the sticker isn't on that message, or the caller isn't
// in the thread. Both are "you can't have this", not "already saved" —
// a repeat save conflicts instead, which also reports zero.
if tag.RowsAffected() == 0 {
var exists bool
if err := r.pg.Pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM saved_stickers ss
JOIN sadhna_rewards sr ON sr.id = ss.reward_id
WHERE ss.user_id = $1 AND sr.key = $2)`,
userID, key).Scan(&exists); err != nil {
return fmt.Errorf("save sticker check: %w", err)
}
if !exists {
return ErrStickerNotOwned
}
}
return nil
}
// CopyAttachments duplicates one message's attachments onto another.
//
// Forwarding needs this: a quote, a sticker, a photo or a shared post lives in
// an attachment row, not in the body, so copying only body+entities forwards
// an empty bubble.
//
// conversation_id is rewritten to the destination, which is what keeps access
// correct: reads authorize against the attachment's OWN conversation, so the
// copy is readable by the new thread's participants and the original stays
// scoped to the old one. Media rows point at the same stored object rather
// than duplicating it.
//
// Entity rows carry only their reference forward; visibility is re-resolved
// per viewer on every read, so forwarding a post cannot leak one.
func (r *ChatRepo) CopyAttachments(ctx context.Context, fromMessageID, toMessageID, toConversationID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO message_attachments
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
width, height, duration_ms, blurhash, file_name,
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot)
SELECT $2, $3, ord, kind, r2_key, mime, size_bytes,
width, height, duration_ms, blurhash, file_name,
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot
FROM message_attachments
WHERE message_id = $1
ORDER BY ord`, fromMessageID, toMessageID, toConversationID)
if err != nil {
return fmt.Errorf("copy attachments: %w", err)
}
return nil
}
// ReactionDetails lists everyone who reacted to one message, newest first.
//
// Separate from ReactionsFor: the grouped counts are what a thread renders,
// and sending the full membership of every reaction on every page would grow
// with the room. This is only read when someone opens the list.
func (r *ChatRepo) ReactionDetails(ctx context.Context, messageID string) ([]model.ReactionDetail, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url, mr.emoji, mr.updated_at
FROM message_reactions mr
JOIN users u ON u.id = mr.user_id
WHERE mr.message_id = $1
ORDER BY mr.updated_at DESC`, messageID)
if err != nil {
return nil, fmt.Errorf("reaction details: %w", err)
}
defer rows.Close()
out := []model.ReactionDetail{}
for rows.Next() {
var d model.ReactionDetail
if err := rows.Scan(&d.User.ID, &d.User.Handle, &d.User.Name,
&d.User.AvatarURL, &d.Emoji, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("scan reaction detail: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// ReactionsFor groups reactions for a page of messages in one query.
//
// Grouped in SQL rather than in Go: the counts are what the UI draws, and
// sending one row per person would grow with the room.
func (r *ChatRepo) ReactionsFor(ctx context.Context, messageIDs []string, viewerID string) (map[string][]model.Reaction, error) {
out := map[string][]model.Reaction{}
if len(messageIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT message_id, emoji, count(*),
bool_or(user_id = $2) AS mine
FROM message_reactions
WHERE message_id = ANY($1)
GROUP BY message_id, emoji
-- Most-used first, then stable by emoji so the chip order doesn't
-- shuffle between reloads.
ORDER BY message_id, count(*) DESC, emoji`, messageIDs, viewerID)
if err != nil {
return nil, fmt.Errorf("reactions for: %w", err)
}
defer rows.Close()
for rows.Next() {
var messageID string
var re model.Reaction
if err := rows.Scan(&messageID, &re.Emoji, &re.Count, &re.Mine); err != nil {
return nil, fmt.Errorf("scan reaction: %w", err)
}
out[messageID] = append(out[messageID], re)
}
return out, rows.Err()
}
// PushCandidate is a participant plus the state that decides whether a new // PushCandidate is a participant plus the state that decides whether a new
// message should reach them out-of-band. // message should reach them out-of-band.
type PushCandidate struct { type PushCandidate struct {
@@ -0,0 +1,143 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrFeedbackCategoryNotFound = errors.New("feedback category not found")
ErrFeedbackCategoryInUse = errors.New("feedback is filed under this category; archive it instead")
ErrFeedbackCategoryExists = errors.New("a category with that name already exists")
)
// FeedbackCategoryRepo stores the /feedback form's categories.
type FeedbackCategoryRepo struct{ pg *Postgres }
func NewFeedbackCategoryRepo(pg *Postgres) *FeedbackCategoryRepo {
return &FeedbackCategoryRepo{pg: pg}
}
// List returns categories in form order. includeArchived is true for the
// admin (which also gets usage counts), false for the public form.
func (r *FeedbackCategoryRepo) List(ctx context.Context, includeArchived bool) ([]model.FeedbackCategory, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT c.key, c.label, c.description, c.sort_order, c.archived_at,
CASE WHEN $1 THEN (SELECT count(*) FROM feedback f WHERE f.category = c.key) ELSE 0 END
FROM feedback_categories c
WHERE $1 OR c.archived_at IS NULL
ORDER BY (c.archived_at IS NOT NULL), c.sort_order, c.label`, includeArchived)
if err != nil {
return nil, fmt.Errorf("list feedback categories: %w", err)
}
defer rows.Close()
out := []model.FeedbackCategory{}
for rows.Next() {
var c model.FeedbackCategory
if err := rows.Scan(&c.Key, &c.Label, &c.Description, &c.SortOrder, &c.ArchivedAt, &c.UsageCount); err != nil {
return nil, fmt.Errorf("scan feedback category: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// IsActive reports whether new feedback may be filed under key.
func (r *FeedbackCategoryRepo) IsActive(ctx context.Context, key string) (bool, error) {
var ok bool
err := r.pg.Pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1 AND archived_at IS NULL)`,
key).Scan(&ok)
if err != nil {
return false, fmt.Errorf("check feedback category: %w", err)
}
return ok, nil
}
// KeyTaken reports whether a key is already used, archived or not.
func (r *FeedbackCategoryRepo) KeyTaken(ctx context.Context, key string) (bool, error) {
var ok bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1)`, key).Scan(&ok)
return ok, err
}
// Create appends a category at the end of the form.
func (r *FeedbackCategoryRepo) Create(ctx context.Context, key, label, description string) (*model.FeedbackCategory, error) {
c := model.FeedbackCategory{Key: key, Label: label, Description: description}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback_categories (key, label, description, sort_order)
VALUES ($1, $2, $3, COALESCE((SELECT max(sort_order) + 1 FROM feedback_categories WHERE archived_at IS NULL), 0))
RETURNING sort_order`, key, label, description).Scan(&c.SortOrder)
if isUniqueViolation(err, "feedback_categories_pkey") {
return nil, ErrFeedbackCategoryExists
}
if err != nil {
return nil, fmt.Errorf("create feedback category: %w", err)
}
return &c, nil
}
// Update changes a category's wording and archived state. Its key — what
// past feedback points at — is never changed.
func (r *FeedbackCategoryRepo) Update(ctx context.Context, key, label, description string, archived bool) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback_categories
SET label = $2, description = $3,
archived_at = CASE WHEN $4 THEN COALESCE(archived_at, now()) ELSE NULL END
WHERE key = $1`, key, label, description, archived)
if err != nil {
return fmt.Errorf("update feedback category: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrFeedbackCategoryNotFound
}
return nil
}
// Reorder sets the form order to the given keys, first to last.
func (r *FeedbackCategoryRepo) Reorder(ctx context.Context, keys []string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin reorder: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
for i, k := range keys {
if _, err := tx.Exec(ctx,
`UPDATE feedback_categories SET sort_order = $2 WHERE key = $1`, k, i); err != nil {
return fmt.Errorf("reorder feedback category: %w", err)
}
}
return tx.Commit(ctx)
}
// Delete removes a category nothing has been filed under. One that's in use
// can only be archived, so no submission is ever left without a label.
func (r *FeedbackCategoryRepo) Delete(ctx context.Context, key string) error {
var used bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM feedback WHERE category = $1)`, key).Scan(&used)
if err != nil {
return fmt.Errorf("check category usage: %w", err)
}
if used {
return ErrFeedbackCategoryInUse
}
tag, err := r.pg.Pool.Exec(ctx, `
DELETE FROM feedback_categories c WHERE c.key = $1
AND NOT EXISTS (SELECT 1 FROM feedback f WHERE f.category = c.key)`, key)
if err != nil {
return fmt.Errorf("delete feedback category: %w", err)
}
if tag.RowsAffected() == 0 {
// Gone already, or feedback arrived between the check and the delete.
if ok, _ := r.KeyTaken(ctx, key); ok {
return ErrFeedbackCategoryInUse
}
return ErrFeedbackCategoryNotFound
}
return nil
}
+13 -10
View File
@@ -37,13 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) { func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{} f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, ` err := r.pg.Pool.QueryRow(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at, SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.acknowledged_at, f.resolved_at,
u.handle, u.name, u.email u.handle, u.name, u.email
FROM feedback f FROM feedback f
LEFT JOIN users u ON u.id = f.user_id LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.id = $1`, id, WHERE f.id = $1`, id,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, ).Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail) &f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil { if err != nil {
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
limit = 50 limit = 50
} }
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, category, message, contact_email, created_at, SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
acknowledged_at, resolved_at, replied_at, reply_message f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message
FROM feedback FROM feedback f
WHERE user_id = $1 LEFT JOIN feedback_categories fc ON fc.key = f.category
ORDER BY created_at DESC WHERE f.user_id = $1
ORDER BY f.created_at DESC
LIMIT $2`, userID, limit, LIMIT $2`, userID, limit,
) )
if err != nil { if err != nil {
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
out := []model.Feedback{} out := []model.Feedback{}
for rows.Next() { for rows.Next() {
var f model.Feedback var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil { &f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
return nil, fmt.Errorf("scan user feedback: %w", err) return nil, fmt.Errorf("scan user feedback: %w", err)
} }
@@ -132,12 +134,13 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
limit = 25 limit = 25
} }
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.created_at, SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.created_at,
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email, (f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
f.acknowledged_at, f.resolved_at, f.acknowledged_at, f.resolved_at,
u.handle, u.name u.handle, u.name
FROM feedback f FROM feedback f
LEFT JOIN users u ON u.id = f.user_id LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
LIMIT $1`, limit, LIMIT $1`, limit,
) )
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{} out := []model.Feedback{}
for rows.Next() { for rows.Next() {
var f model.Feedback var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt, if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.CreatedAt,
&f.HasContactEmail, &f.HasContactEmail,
&f.AcknowledgedAt, &f.ResolvedAt, &f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil { &f.AuthorHandle, &f.AuthorName); err != nil {
+2
View File
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
END AS rank END AS rank
FROM users u FROM users u
WHERE u.id <> $1 WHERE u.id <> $1
-- A suspended account can't be @mentioned into a post.
AND u.banned_at IS NULL
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3) AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY rank, u.name, u.id ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`, LIMIT $4 OFFSET $5`,
+734
View File
@@ -0,0 +1,734 @@
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")
// ErrQuizTargetTaken: that chapter, lecture or conversation already has a
// quiz (enforced by the quizzes_one_per_target index).
ErrQuizTargetTaken = errors.New("only one quiz is allowed per link")
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
TargetYear *int
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.target_year, 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.TargetYear, &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, target_year, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, createdBy,
).Scan(&id); err != nil {
if isUniqueViolation(err, "quizzes_one_per_target") {
return "", ErrQuizTargetTaken
}
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, target_year = $7, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear)
if err != nil {
if isUniqueViolation(err, "quizzes_one_per_target") {
return ErrQuizTargetTaken
}
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
}
// QuizOnTarget returns the live quiz (published or draft) already attached to
// a content path, other than exceptID — or "" when the path is free.
func (r *QuizRepo) QuizOnTarget(ctx context.Context, path, exceptID string) (id, title string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT id, title FROM quizzes
WHERE target_path = $1 AND deleted_at IS NULL
AND ($2 = '' OR id::text <> $2)
LIMIT 1`, path, exceptID).Scan(&id, &title)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("quiz on target: %w", err)
}
return id, title, 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
}
// ResetAttempt deletes one person's attempt — its answers go with it — so the
// quiz is pending for them again and they can take it afresh.
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
if err != nil {
return fmt.Errorf("reset attempt: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotStarted
}
return nil
}
// ── Taking a quiz ─────────────────────────────────────────────────────────
// PublishedForPath returns the newest live quiz on a content page, or nil.
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
}
+110 -4
View File
@@ -4,12 +4,17 @@ import (
"context" "context"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
) )
// ErrRewardNotFound is an unknown or retired reward key.
var ErrRewardNotFound = errors.New("reward not found")
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the // rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is // repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
// embedded so the BFF can seed/sync the catalog on startup with no external // embedded so the BFF can seed/sync the catalog on startup with no external
@@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er
// reward embedded for direct rendering. // reward embedded for direct rendering.
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) { func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT g.milestone, g.granted_at, SELECT g.milestone, g.granted_at, g.source, g.celebrated_at IS NOT NULL,
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
FROM sadhna_reward_grants g FROM sadhna_reward_grants g
JOIN sadhna_rewards w ON w.id = g.reward_id JOIN sadhna_rewards w ON w.id = g.reward_id
WHERE g.user_id = $1 WHERE g.user_id = $1
ORDER BY g.granted_at DESC, g.milestone DESC`, -- NULLS LAST so gifts, which have no milestone, sort among the rest
-- by date rather than jumping to the top.
ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`,
userID, userID,
) )
if err != nil { if err != nil {
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
for rows.Next() { for rows.Next() {
var g model.SadhnaRewardGrant var g model.SadhnaRewardGrant
if err := rows.Scan( if err := rows.Scan(
&g.Milestone, &g.GrantedAt, &g.Milestone, &g.GrantedAt, &g.Source, &g.Opened,
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
); err != nil { ); err != nil {
@@ -180,8 +187,106 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
return nil return nil
} }
// CreateCustom adds a reward to the catalog from the admin panel.
//
// Custom entries survive re-seeding: Seed upserts only the keys in its
// embedded JSON, and these keys are namespaced away from those.
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
VALUES ($1, $2, $3, $4, true, 1000)
RETURNING id, key, name, meaning, image_url, active, sort`,
key, name, meaning, imageURL,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if err != nil {
return nil, fmt.Errorf("create custom reward: %w", err)
}
return &w, nil
}
// DeleteCustom removes an admin-made reward from the catalog.
//
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
// the artwork onto the message rather than referencing the catalog, so history
// stays intact.
//
// Restricted to custom keys. A seeded reward would come straight back on the
// next Seed run, and deleting one would rip a streak reward out of the
// galleries of everyone who earned it.
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
if err != nil {
return fmt.Errorf("delete custom reward: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRewardNotFound
}
return nil
}
// ByKey looks up one reward from the catalog.
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, meaning, image_url, active, sort
FROM sadhna_rewards WHERE key = $1 AND active`, key,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRewardNotFound
}
if err != nil {
return nil, fmt.Errorf("reward by key: %w", err)
}
return &w, nil
}
// GrantGiftToEveryone hands one reward to every account, unopened.
//
// Idempotent by the partial unique index: re-running adds it only to people
// who don't have it, so a mis-click can't create duplicate tiles. Returns how
// many accounts it reached.
func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, source)
SELECT u.id, $1, 'gift'
FROM users u
WHERE u.banned_at IS NULL
ON CONFLICT DO NOTHING`, rewardID)
if err != nil {
return 0, fmt.Errorf("grant gift: %w", err)
}
return tag.RowsAffected(), nil
}
// OpenGift marks one gift as seen, which is what un-blurs its tile.
//
// Scoped to a single reward rather than clearing everything pending: a devotee
// with two gifts waiting should open them one at a time.
func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE sadhna_reward_grants g
SET celebrated_at = now()
FROM sadhna_rewards w
WHERE w.id = g.reward_id
AND g.user_id = $1 AND w.key = $2
AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey)
if err != nil {
return fmt.Errorf("open gift: %w", err)
}
return nil
}
// PendingGrant returns the most recent grant the client hasn't celebrated // PendingGrant returns the most recent grant the client hasn't celebrated
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti. // yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
//
// STREAK grants only. An admin gift is also uncelebrated until opened, but it
// waits as a blurred tile in the gallery rather than ambushing the devotee
// with confetti the moment they load the page.
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) { func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
var g model.SadhnaRewardGrant var g model.SadhnaRewardGrant
err := r.pg.Pool.QueryRow(ctx, ` err := r.pg.Pool.QueryRow(ctx, `
@@ -189,7 +294,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
FROM sadhna_reward_grants g FROM sadhna_reward_grants g
JOIN sadhna_rewards w ON w.id = g.reward_id JOIN sadhna_rewards w ON w.id = g.reward_id
WHERE g.user_id = $1 AND g.celebrated_at IS NULL WHERE g.user_id = $1 AND g.celebrated_at IS NULL AND g.source = 'streak'
ORDER BY g.granted_at DESC, g.milestone DESC ORDER BY g.granted_at DESC, g.milestone DESC
LIMIT 1`, LIMIT 1`,
userID, userID,
@@ -198,6 +303,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
) )
g.Source = "streak"
if err != nil { if err != nil {
return nil, nil // no rows / nothing pending return nil, nil // no rows / nothing pending
} }
+241
View File
@@ -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
}
+2 -1
View File
@@ -39,7 +39,8 @@ type CategoryCount struct {
// the table sizes are modest enough that chasing parallelism here // the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity. // would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) { func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{} // Non-nil so an empty table serialises as [] rather than null.
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour) sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct { scalars := []struct {
+13 -2
View File
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// enough at our user-table scale; revisit alongside the pg_trgm switch. // enough at our user-table scale; revisit alongside the pg_trgm switch.
var total int var total int
if err := r.pg.Pool.QueryRow(ctx, if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`, `SELECT COUNT(*) FROM users
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
substring).Scan(&total); err != nil { substring).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count search users: %w", err) return nil, 0, fmt.Errorf("count search users: %w", err)
} }
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// and name could reshuffle between pages. // and name could reshuffle between pages.
sql := `SELECT ` + userColumns + ` sql := `SELECT ` + userColumns + `
FROM users FROM users
WHERE name ILIKE $1 OR handle::text ILIKE $1 WHERE (name ILIKE $1 OR handle::text ILIKE $1)
-- Suspended accounts are unsearchable. This one query backs the
-- people search page, the mention autocomplete AND both chat member
-- pickers, so a banned account appearing here is offered as someone
-- to @mention or add to a group. ResolveHandles already refuses them
-- on the way in, which without this makes "Add" silently do nothing.
AND banned_at IS NULL
ORDER BY ORDER BY
CASE CASE
WHEN handle::text ILIKE $2 THEN 0 WHEN handle::text ILIKE $2 THEN 0
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
FROM users u FROM users u
WHERE u.created_at >= $2 WHERE u.created_at >= $2
AND u.id <> $1 AND u.id <> $1
-- Suspended accounts are not discoverable. A ban hides someone from
-- the app; surfacing them in the welcome strip would be the most
-- prominent placement on the home page.
AND u.banned_at IS NULL
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM follows f SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id WHERE f.follower_id = $1 AND f.following_id = u.id
+166
View File
@@ -16,6 +16,13 @@ import (
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres // VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
// (vani_of_the_day) while the candidate content is sampled from Mongo (the // (vani_of_the_day) while the candidate content is sampled from Mongo (the
// verses / prose_chapters / transcriptions collections). // verses / prose_chapters / transcriptions collections).
var (
// ErrVaniRefInvalid is a malformed content id.
ErrVaniRefInvalid = errors.New("not a valid content reference")
// ErrVaniRefNotFound is a well-formed id that matches nothing.
ErrVaniRefNotFound = errors.New("that content no longer exists")
)
type VaniRepo struct { type VaniRepo struct {
pg *Postgres pg *Postgres
mongo *MongoDB mongo *MongoDB
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
return nil return nil
} }
// Upsert writes a day's pick, REPLACING any existing one.
//
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
// picker must never overwrite a day already decided. An admin choosing the day
// deliberately is the one case that should win.
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
b, err := json.Marshal(pick.Payload)
if err != nil {
return err
}
_, err = r.pg.Pool.Exec(ctx,
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (day) DO UPDATE
SET kind = EXCLUDED.kind,
ref_id = EXCLUDED.ref_id,
payload = EXCLUDED.payload`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("upsert vani pick: %w", err)
}
return nil
}
// Clear removes a day's pick, so the automatic picker chooses again.
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
if err != nil {
return fmt.Errorf("clear vani pick: %w", err)
}
return nil
}
// PicksFrom lists decided days from `from` onward, for the admin schedule.
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
if err != nil {
return nil, fmt.Errorf("list vani picks: %w", err)
}
defer rows.Close()
out := []model.VaniPick{}
for rows.Next() {
var p model.VaniPick
var raw []byte
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
return nil, fmt.Errorf("scan vani pick: %w", err)
}
if err := json.Unmarshal(raw, &p.Payload); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// BookTitle resolves a book code to its display title (from book_meta.data.title), // BookTitle resolves a book code to its display title (from book_meta.data.title),
// or "" if unknown. Used to label a prose pick with its book name. Decoding into // or "" if unknown. Used to label a prose pick with its book name. Decoding into
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode // a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
@@ -135,6 +200,107 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
return false, cur.Err() return false, cur.Err()
} }
// oneByHex loads a single document by its _id hex. Shared by the three
// admin-pick lookups, which differ only in collection and target type.
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
oid, err := bson.ObjectIDFromHex(hex)
if err != nil {
return ErrVaniRefInvalid
}
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
if errors.Is(err, mongo.ErrNoDocuments) {
return ErrVaniRefNotFound
}
if err != nil {
return fmt.Errorf("load %s %s: %w", coll, hex, err)
}
return nil
}
// TranscriptBySlug loads a lecture by its URL slug.
//
// Search cannot do this: the slug is not an indexed field, so querying
// "720720sb-london" matches on tokens like "london" and returns whatever else
// mentions it. Resolving the slug directly is the only reliable way to pin a
// specific item someone already has the URL for.
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
err := r.mongo.Collection("transcriptions").
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("transcript by slug: %w", err)
}
return &t, nil
}
// ProseBySlug loads a prose chapter by book and slug.
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
{Key: "book", Value: book},
{Key: "slug", Value: slug},
}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("prose by slug: %w", err)
}
return &p, nil
}
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
// which is what a verse URL carries.
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
filter := bson.D{
{Key: "book", Value: book},
{Key: "chapter", Value: chapter},
{Key: "verse", Value: verse},
}
if division != nil && *division != "" {
filter = append(filter, bson.E{Key: "division", Value: *division})
}
var v VerseDoc
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("verse by ref: %w", err)
}
return &v, nil
}
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
// chosen. The payload is then built by the same builders the random picker
// uses, so a pinned pick renders identically to an automatic one.
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
var v VerseDoc
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
return nil, err
}
return &v, nil
}
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
return nil, err
}
return &p, nil
}
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
return nil, err
}
return &t, nil
}
// SampleVerse returns a random verse with a translation, excluding the given // SampleVerse returns a random verse with a translation, excluding the given
// _id hexes. Returns nil when nothing matches. // _id hexes. Returns nil when nothing matches.
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) { func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
+143 -39
View File
@@ -12,6 +12,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config" "github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/handler" "github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/internal/worker" "github.com/prabhupadasays/spsays-bff/internal/worker"
@@ -75,9 +76,13 @@ type Server struct {
pushH *handler.PushHandler pushH *handler.PushHandler
reminderH *handler.ReminderHandler reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler feedbackH *handler.FeedbackHandler
feedbackCatH *handler.FeedbackCategoryHandler
statsH *handler.StatsHandler statsH *handler.StatsHandler
adminH *handler.AdminHandler adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler broadcastH *handler.BroadcastHandler
staffRepo *repository.StaffRepo
staffH *handler.StaffHandler
quizH *handler.QuizHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503) // Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer authIssuer *auth.Issuer
@@ -224,6 +229,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo) vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger)) s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
// target title and kind.
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
// OAuth — wired only when at least one provider is configured. The // OAuth — wired only when at least one provider is configured. The
// registry skips providers with missing creds, so partial configs (e.g. // registry skips providers with missing creds, so partial configs (e.g.
// Google only, no Facebook) work out of the box. // Google only, no Facebook) work out of the box.
@@ -266,7 +275,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Chat — DMs, groups and the (still coming-soon) Kautilya threads. // Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg) chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo, chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
pushSender, notifPrefsRepo, userRepo, rtClient, r2, s.redis, logger) pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc) s.chatH = handler.NewChatHandler(chatSvc)
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a // Soketi signs webhooks with the app secret. channel_occupied/vacated on a
@@ -327,15 +337,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
if err := rewardRepo.Seed(context.Background()); err != nil { if err := rewardRepo.Seed(context.Background()); err != nil {
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err)) logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
} }
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger) sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, r2, logger)
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc) s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
// Feedback + stats — share Postgres with the rest of /api/v1 and // Feedback + stats — share Postgres with the rest of /api/v1 and
// the existing mailer instance. // the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(s.pg) feedbackRepo := repository.NewFeedbackRepo(s.pg)
statsRepo := repository.NewStatsRepo(s.pg) statsRepo := repository.NewStatsRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger) // Categories are admin-managed; submissions are checked against them.
feedbackCatRepo := repository.NewFeedbackCategoryRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, feedbackCatRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc) s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
s.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo) s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
// Admin panel — read-mostly listing + notify/reply/resolve actions. // Admin panel — read-mostly listing + notify/reply/resolve actions.
@@ -343,7 +356,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Gated by RequireAuth + RequireAdmin in the router. // Gated by RequireAuth + RequireAdmin in the router.
adminRepo := repository.NewAdminRepo(s.pg) adminRepo := repository.NewAdminRepo(s.pg)
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL) adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc) // Admin RBAC: who is staff, with which permissions.
s.staffRepo = repository.NewStaffRepo(s.pg)
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
// Broadcasts — admin announcements shown once per user. No notification // Broadcasts — admin announcements shown once per user. No notification
// rows: the modal is the surface, and push plus the realtime nudge only // rows: the modal is the surface, and push plus the realtime nudge only
@@ -478,8 +494,14 @@ func (s *Server) setupRouter() {
// CORS — the main frontend plus, when configured, the admin panel (a // CORS — the main frontend plus, when configured, the admin panel (a
// separate origin). Empty origins are filtered so the list never widens // separate origin). Empty origins are filtered so the list never widens
// unintentionally. // unintentionally.
adminURL := s.cfg.AdminURL
if adminURL == "" && s.cfg.Env == "development" {
// The admin app's dev server (`next dev -p 3001`), so local admin
// work needs no extra config. Production must set ADMIN_URL.
adminURL = "http://localhost:3001"
}
allowedOrigins := []string{} allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} { for _, o := range []string{s.cfg.FrontendURL, adminURL} {
if o != "" { if o != "" {
allowedOrigins = append(allowedOrigins, o) allowedOrigins = append(allowedOrigins, o)
} }
@@ -707,6 +729,26 @@ func (s *Server) setupRouter() {
// Vāṇī of the Day — public daily content pick for the home page. // Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today) r.Get("/vani/today", s.vaniH.Today)
// Quizzes are for signed-in users: listing, finding one on a page,
// taking it and reading results all need an account. The one open
// endpoint is a quiz's own card — title, description, what it's
// attached to, never its questions — so link previews (OpenGraph)
// can name the quiz when it's shared.
r.Route("/quizzes", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}", s.quizH.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.quizH.List)
r.Get("/for", s.quizH.ForPath)
r.Post("/{id}/start", s.quizH.Start)
r.Post("/{id}/answer", s.quizH.Answer)
r.Get("/{id}/result", s.quizH.Result)
})
})
// --- Auth --- // --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset, // Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes // and email verification. The /auth/me + /auth/resend-verification routes
@@ -935,6 +977,7 @@ func (s *Server) setupRouter() {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox) r.Get("/conversations", s.chatH.Inbox)
r.Get("/unread", s.chatH.Unread) r.Get("/unread", s.chatH.Unread)
r.Get("/stickers", s.chatH.Stickers)
r.Post("/conversations/dm", s.chatH.OpenDM) r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup) r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread) r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
@@ -951,6 +994,7 @@ func (s *Server) setupRouter() {
r.Get("/messages", s.chatH.Messages) r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send) r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead) r.Post("/read", s.chatH.MarkRead)
r.Post("/typing", s.chatH.Typing)
r.Post("/delivered", s.chatH.MarkDelivered) r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts) r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute) r.Post("/mute", s.chatH.SetMute)
@@ -961,6 +1005,11 @@ func (s *Server) setupRouter() {
r.Get("/starred", s.chatH.Starred) r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star) r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar) r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Get("/messages/{messageID}", s.chatH.Message)
r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions)
r.Post("/messages/{messageID}/sticker", s.chatH.SaveSticker)
r.Put("/messages/{messageID}/reaction", s.chatH.React)
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage) r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage) r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward) r.Post("/forward", s.chatH.Forward)
@@ -1067,6 +1116,8 @@ func (s *Server) setupRouter() {
r.HandleFunc("/feedback/mine", s.authUnavailable) r.HandleFunc("/feedback/mine", s.authUnavailable)
return return
} }
// The form's categories — public, like the form itself.
r.Get("/feedback/categories", s.feedbackCatH.Public)
// Submit — OptionalAuth (anonymous callers allowed). // Submit — OptionalAuth (anonymous callers allowed).
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
@@ -1125,6 +1176,7 @@ func (s *Server) setupRouter() {
r.Post("/chant", s.sadhnaH.SaveChant) r.Post("/chant", s.sadhnaH.SaveChant)
r.Get("/rewards", s.sadhnaH.Rewards) r.Get("/rewards", s.sadhnaH.Rewards)
r.Post("/rewards/ack", s.sadhnaH.AckRewards) r.Post("/rewards/ack", s.sadhnaH.AckRewards)
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
if s.reminderH != nil { if s.reminderH != nil {
r.Get("/reminders", s.reminderH.GetPrefs) r.Get("/reminders", s.reminderH.GetPrefs)
r.Put("/reminders", s.reminderH.UpdatePrefs) r.Put("/reminders", s.reminderH.UpdatePrefs)
@@ -1141,41 +1193,93 @@ func (s *Server) setupRouter() {
return return
} }
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Use(middleware.RequireAdmin(s.userRepo)) // Staff only past this point; each route then names the one
r.Get("/stats", s.adminH.Stats) // permission it needs. A route without a permission is reachable
r.Get("/users", s.adminH.ListUsers) // by every staff member, so only /me is left bare.
r.Post("/users/{id}/notify", s.adminH.NotifyUser) r.Use(middleware.RequireStaff(s.staffRepo))
r.Post("/users/{id}/ban", s.adminH.BanUser) can := func(perm string) chi.Router {
r.Delete("/users/{id}/ban", s.adminH.UnbanUser) return r.With(middleware.RequirePermission(perm))
r.Post("/users/{id}/limits", s.adminH.SetUserLimits) }
// Direct-disciple linking: mark an account as one of Śrīla
// Prabhupāda's initiated disciples (or clear the mark).
r.Get("/disciples", s.adminH.ListDisciples)
// Announcements composed and published from the admin panel. r.Get("/me", s.staffH.Me)
r.Get("/broadcasts", s.broadcastH.List)
r.Post("/broadcasts", s.broadcastH.Create) // Staff and roles — superadmins only.
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner) can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish) can(rbac.ManageStaff).Get("/staff", s.staffH.List)
r.Delete("/broadcasts/{id}", s.broadcastH.Delete) can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple) can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple) can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
r.Get("/posts", s.adminH.ListPosts) can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
r.Post("/posts/{id}/hide", s.adminH.HidePost) can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost) can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
r.Delete("/posts/{id}", s.adminH.DeletePost) can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
r.Post("/posts/{id}/restore", s.adminH.RestorePost) can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
r.Delete("/comments/{id}", s.adminH.DeleteComment) can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
r.Get("/collections", s.adminH.ListCollections) can("stats:read").Get("/stats", s.adminH.Stats)
r.Get("/feedback", s.adminH.ListFeedback)
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) can("users:read").Get("/users", s.adminH.ListUsers)
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback) can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
r.Get("/reports", s.adminH.ListReports) can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
r.Get("/short-urls", s.adminH.ListShortURLs) can("users:read").Get("/disciples", s.adminH.ListDisciples)
r.Get("/short-urls/stats", s.adminH.ShortURLStats) 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:read").Get("/feedback/categories", s.feedbackCatH.List)
can("feedback:categories").Post("/feedback/categories", s.feedbackCatH.Create)
can("feedback:categories").Put("/feedback/categories/order", s.feedbackCatH.Reorder)
can("feedback:categories").Put("/feedback/categories/{key}", s.feedbackCatH.Update)
can("feedback:categories").Delete("/feedback/categories/{key}", s.feedbackCatH.Delete)
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("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
}) })
}) })
+4 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -335,7 +336,8 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
var buf bytes.Buffer var buf bytes.Buffer
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{ if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
"RecipientName": name, "RecipientName": name,
"Reply": message, // Rendered markers; escaped by richtext, newlines become <br>.
"Reply": richtext.ToHTML(message),
"URL": s.frontendURL, "URL": s.frontendURL,
}); err != nil { }); err != nil {
s.logger.Warn("render feedback reply email failed", zap.Error(err)) s.logger.Warn("render feedback reply email failed", zap.Error(err))
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
<p>Thank you for writing to us. Here's our reply to your feedback:</p> <p>Thank you for writing to us. Here's our reply to your feedback:</p>
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote> <blockquote style="border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
<p style="margin:1.25em 0"> <p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a> <a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
+3 -1
View File
@@ -223,7 +223,9 @@ func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string
if !ok || kind != model.AttachmentImage { if !ok || kind != model.AttachmentImage {
return "", "", ErrChatBadMedia return "", "", ErrChatBadMedia
} }
return presignImage(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType) // Returns the KEY: broadcasts.banner_key stores a key, and withBanner
// builds the URL from it at read time.
return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
} }
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) { func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
+348 -19
View File
@@ -1,12 +1,14 @@
package service package service
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/url" "net/url"
"path" "path"
"strings" "strings"
@@ -14,6 +16,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/unfurl" "github.com/prabhupadasays/spsays-bff/pkg/unfurl"
"go.uber.org/zap" "go.uber.org/zap"
@@ -43,6 +46,7 @@ var (
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you") ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction") ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
ErrChatBadQuote = errors.New("that quote is not a valid passage reference") ErrChatBadQuote = errors.New("that quote is not a valid passage reference")
ErrChatBadEmoji = errors.New("that is not a valid reaction")
ErrChatBadEntity = errors.New("that post cannot be shared") ErrChatBadEntity = errors.New("that post cannot be shared")
ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months") ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months")
ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option") ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option")
@@ -71,6 +75,10 @@ type ChatService struct {
push *PushSender push *PushSender
prefs *repository.NotificationPrefsRepo prefs *repository.NotificationPrefsRepo
users *repository.UserRepo users *repository.UserRepo
// mail nudges someone who has never used this thread and isn't connected.
// Optional: nil disables first-contact email and nothing else.
mail mailer.Mailer
frontendURL string
rt *realtime.SoketiClient rt *realtime.SoketiClient
r2 *repository.R2 r2 *repository.R2
links *unfurl.Fetcher links *unfurl.Fetcher
@@ -85,6 +93,8 @@ func NewChatService(
push *PushSender, push *PushSender,
prefs *repository.NotificationPrefsRepo, prefs *repository.NotificationPrefsRepo,
users *repository.UserRepo, users *repository.UserRepo,
mail mailer.Mailer,
frontendURL string,
rt *realtime.SoketiClient, rt *realtime.SoketiClient,
r2 *repository.R2, r2 *repository.R2,
cache *repository.Redis, cache *repository.Redis,
@@ -92,7 +102,8 @@ func NewChatService(
) *ChatService { ) *ChatService {
return &ChatService{ return &ChatService{
chat: chat, blocks: blocks, posts: posts, chat: chat, blocks: blocks, posts: posts,
push: push, prefs: prefs, users: users, rt: rt, r2: r2, push: push, prefs: prefs, users: users, mail: mail,
frontendURL: frontendURL, rt: rt, r2: r2,
links: unfurl.New(), cache: cache, logger: logger, links: unfurl.New(), cache: cache, logger: logger,
} }
} }
@@ -350,6 +361,12 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m
if err != nil { if err != nil {
return nil, err return nil, err
} }
// One grouped query for the whole page. Counts depend on the viewer
// (`mine`), which is why this is hydrated per request rather than cached.
reactions, err := s.chat.ReactionsFor(ctx, ids, viewerID)
if err != nil {
return nil, err
}
for i := range msgs { for i := range msgs {
if a := byMessage[msgs[i].ID]; a != nil { if a := byMessage[msgs[i].ID]; a != nil {
msgs[i].Attachments = a msgs[i].Attachments = a
@@ -357,6 +374,11 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m
// Always a slice, never nil, so clients need no guard. // Always a slice, never nil, so clients need no guard.
msgs[i].Attachments = []model.Attachment{} msgs[i].Attachments = []model.Attachment{}
} }
if r := reactions[msgs[i].ID]; r != nil {
msgs[i].Reactions = r
} else {
msgs[i].Reactions = []model.Reaction{}
}
} }
pages := make([][]model.Attachment, 0, len(msgs)) pages := make([][]model.Attachment, 0, len(msgs))
for i := range msgs { for i := range msgs {
@@ -406,6 +428,9 @@ type SendRequest struct {
PreviewURL *string PreviewURL *string
// Entity is something else in the app — a post — shared as a card. // Entity is something else in the app — a post — shared as a card.
Entity *EntityAttachment Entity *EntityAttachment
// StickerKey sends a piece of reward artwork. Ownership is checked
// server-side; the key alone proves nothing.
StickerKey *string
// Quote is a passage shared from the library, rendered as a quote the // Quote is a passage shared from the library, rendered as a quote the
// reader can tap to return to the exact phrase. // reader can tap to return to the exact phrase.
Quote *QuoteAttachment Quote *QuoteAttachment
@@ -446,7 +471,8 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
body := strings.TrimSpace(req.Body) body := strings.TrimSpace(req.Body)
// A photo or a shared passage with no caption is an ordinary message; only // A photo or a shared passage with no caption is an ordinary message; only
// a message with nothing at all in it is empty. // a message with nothing at all in it is empty.
if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil { if body == "" && len(req.Attachments) == 0 && req.Quote == nil &&
req.Entity == nil && req.StickerKey == nil {
return nil, ErrChatEmptyBody return nil, ErrChatEmptyBody
} }
if len([]rune(body)) > maxBodyRunes { if len([]rune(body)) > maxBodyRunes {
@@ -478,9 +504,15 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
// the first time, and re-publishing would duplicate the bubble for // the first time, and re-publishing would duplicate the bubble for
// everyone else. // everyone else.
if !created { if !created {
return s.withAttachments(ctx, userID, msg) return s.hydrateSent(ctx, userID, conversationID, msg), nil
} }
if req.StickerKey != nil {
if err := s.attachSticker(ctx, conversationID, userID, msg, *req.StickerKey); err != nil {
s.logger.Warn("chat: sticker refused",
zap.String("user_id", userID), zap.Error(err))
}
}
if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil { if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil {
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err)) s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
} }
@@ -488,14 +520,33 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments) s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL) s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
full, err := s.withAttachments(ctx, userID, msg) full := s.hydrateSent(ctx, userID, conversationID, msg)
if err != nil {
full = msg
}
s.publish(ctx, conversationID, userID, full) s.publish(ctx, conversationID, userID, full)
return full, nil return full, nil
} }
// hydrateSent re-reads a just-written message through the full read path.
//
// Append RETURNINGs the row it inserted, which carries reply_to_id but not the
// quoted preview — that is built by a LATERAL join only the read query has. So
// a reply sent over the wire, and the realtime event announcing it, would both
// arrive with nothing to quote and stay that way until the thread was
// reloaded. Reading it back costs one query and makes the sent message
// identical to the one everyone else receives.
func (s *ChatService) hydrateSent(ctx context.Context, viewerID, conversationID string, msg *model.Message) *model.Message {
full, err := s.chat.Message(ctx, conversationID, msg.ID, viewerID)
if err != nil {
// Fall back to the unhydrated row: a missing quote is better than a
// message that appears not to have sent at all.
full = msg
}
hydrated, err := s.withAttachments(ctx, viewerID, full)
if err != nil {
return full
}
return hydrated
}
// withAttachments hydrates a message's attachments for the response. // withAttachments hydrates a message's attachments for the response.
func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) { func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) {
byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID}) byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID})
@@ -506,6 +557,12 @@ func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg
if msg.Attachments == nil { if msg.Attachments == nil {
msg.Attachments = []model.Attachment{} msg.Attachments = []model.Attachment{}
} }
if reactions, rErr := s.chat.ReactionsFor(ctx, []string{msg.ID}, viewerID); rErr == nil {
msg.Reactions = reactions[msg.ID]
}
if msg.Reactions == nil {
msg.Reactions = []model.Reaction{}
}
s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments}) s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments})
return msg, nil return msg, nil
} }
@@ -1033,10 +1090,14 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC
return nil, err return nil, err
} }
if created { if created {
full, hErr := s.withAttachments(ctx, userID, msg) // Carry the attachments across. Without this a quote, sticker, photo
if hErr == nil { // or shared post forwards as an empty bubble: its content lives in an
msg = full // attachment row, not in the body.
if err := s.chat.CopyAttachments(ctx, messageID, msg.ID, toConversationID); err != nil {
s.logger.Warn("chat: forwarding attachments failed",
zap.String("message_id", messageID), zap.Error(err))
} }
msg = s.hydrateSent(ctx, userID, toConversationID, msg)
s.publish(ctx, toConversationID, userID, msg) s.publish(ctx, toConversationID, userID, msg)
} }
return msg, nil return msg, nil
@@ -1552,6 +1613,191 @@ func (s *ChatService) IsMuted(ctx context.Context, userID, conversationID string
return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil
} }
// HeartEmoji is what a double-tap sends, and the one reaction with its own
// notification copy.
const HeartEmoji = "❤️"
// maxEmojiRunes bounds a reaction. Generous enough for a flag or a skin-tone
// sequence, small enough that the column can't be used as free text storage.
const maxEmojiRunes = 8
// MessageReactions lists who reacted to a message and with what.
//
// Authorized against the message's OWN conversation, so a message id alone
// never reveals who is in a thread the caller isn't part of.
func (s *ChatService) MessageReactions(ctx context.Context, userID, messageID string) ([]model.ReactionDetail, error) {
conversationID, _, err := s.chat.MessageConversation(ctx, messageID)
if err != nil {
return nil, err
}
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
return nil, err
}
return s.chat.ReactionDetails(ctx, messageID)
}
// Message returns one hydrated message for this viewer.
//
// Exists because reaction counts carry a per-viewer `mine` flag, so a client
// told "message X changed" cannot compute the new chips itself — it has to ask.
func (s *ChatService) Message(ctx context.Context, userID, conversationID, messageID string) (*model.Message, error) {
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
return nil, err
}
msg, err := s.chat.Message(ctx, conversationID, messageID, userID)
if err != nil {
return nil, err
}
return s.withAttachments(ctx, userID, msg)
}
// Stickers lists what the caller can send: sādhana rewards they earned, plus
// stickers they saved from a chat.
func (s *ChatService) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
return s.chat.Stickers(ctx, userID)
}
// SaveSticker keeps one someone sent, after checking it really is on that
// message and the caller is in the thread.
func (s *ChatService) SaveSticker(ctx context.Context, userID, messageID, key string) error {
return s.chat.SaveSticker(ctx, userID, key, messageID)
}
// attachSticker stores the artwork on a message.
//
// Ownership is checked here, not trusted from the client: the key comes over
// the wire, and without this anyone could send any sticker in the catalog by
// guessing its key. The image and name are denormalized because the catalog
// is static art — there is nothing viewer-dependent to re-resolve on read.
func (s *ChatService) attachSticker(ctx context.Context, conversationID, userID string, msg *model.Message, key string) error {
sticker, err := s.chat.Sticker(ctx, userID, key)
if err != nil {
return err
}
entityType := model.EntitySticker
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
Kind: model.AttachmentSticker, EntityType: &entityType,
EntityID: &sticker.Key, ImageURL: &sticker.ImageURL, Title: &sticker.Name,
}); err != nil {
s.logger.Warn("chat: attach sticker failed", zap.Error(err))
}
return nil
}
// Typing broadcasts that someone is composing.
//
// Never persisted and never pushed: a typing indicator is only meaningful to
// someone already looking at the thread, and it is stale within seconds.
// Publishing failures are ignored for the same reason.
func (s *ChatService) Typing(ctx context.Context, userID, conversationID string) error {
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
return err
}
if s.rt == nil {
return nil
}
if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "typing", map[string]any{
"user_id": userID,
}); err != nil {
s.logger.Debug("chat: typing publish failed", zap.Error(err))
}
return nil
}
// React sets the caller's emoji on a message, replacing any previous one.
//
// Authorization is by membership of the message's OWN conversation, read back
// from the insert — a message id alone must not let someone react into a
// thread they were never in.
func (s *ChatService) React(ctx context.Context, userID, messageID, emoji string) error {
emoji = strings.TrimSpace(emoji)
if emoji == "" || len([]rune(emoji)) > maxEmojiRunes {
return ErrChatBadEmoji
}
conversationID, author, err := s.chat.MessageConversation(ctx, messageID)
if err != nil {
return err
}
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
return err
}
if _, err := s.chat.SetReaction(ctx, messageID, userID, emoji); err != nil {
return err
}
s.publishReaction(ctx, conversationID, messageID)
s.notifyReaction(ctx, conversationID, author, userID, emoji)
return nil
}
// Unreact removes the caller's reaction.
func (s *ChatService) Unreact(ctx context.Context, userID, messageID string) error {
conversationID, _, err := s.chat.MessageConversation(ctx, messageID)
if err != nil {
return err
}
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
return err
}
if _, err := s.chat.ClearReaction(ctx, messageID, userID); err != nil {
// Nothing to remove is the desired end state, not a failure.
if errors.Is(err, repository.ErrMessageNotHere) {
return nil
}
return err
}
s.publishReaction(ctx, conversationID, messageID)
return nil
}
// publishReaction tells the open thread to refresh one message's chips. The
// event carries only the id: counts depend on the viewer, so each client
// re-reads its own.
func (s *ChatService) publishReaction(ctx context.Context, conversationID, messageID string) {
if s.rt == nil {
return
}
if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "reaction", map[string]any{
"message_id": messageID,
}); err != nil {
s.logger.Debug("chat: reaction publish failed", zap.Error(err))
}
}
// notifyReaction pushes to the message's author.
//
// Never for reacting to your own message, and it reuses the chat push rules —
// mute, the Messages preference, presence and the debounce — so a reaction
// cannot become a way around them.
func (s *ChatService) notifyReaction(ctx context.Context, conversationID string, author *string, actorID, emoji string) {
if s.push == nil || !s.push.Enabled() {
return
}
// No author on system and assistant messages, and reacting to your own
// message must never notify you.
if author == nil || *author == actorID {
return
}
conv, err := s.chat.Conversation(ctx, conversationID)
if err != nil {
return
}
actor := "Someone"
if s.users != nil {
if u, uErr := s.users.FindByID(ctx, actorID); uErr == nil && u != nil && u.Name != "" {
actor = u.Name
}
}
body := actor + " reacted " + emoji + " to your message"
if emoji == HeartEmoji {
body = actor + " liked your message"
}
s.pushTo(ctx, conv, *author, actor, body)
}
// Receipts reports how far EVERY other participant has got, which is what // Receipts reports how far EVERY other participant has got, which is what
// draws the caller's tick states. // draws the caller's tick states.
// //
@@ -1697,28 +1943,111 @@ func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversa
if c.MutedUntil != nil && c.MutedUntil.After(now) { if c.MutedUntil != nil && c.MutedUntil.After(now) {
continue continue
} }
// First contact: push only reaches a device that has already opted
// in, which someone who has never used chat almost certainly hasn't.
// Email is the one channel that reaches them — and only the FIRST
// message qualifies, since after that the thread exists and push
// carries it.
if msg.Seq == 1 && !s.online(ctx, c.UserID) {
s.emailFirstContact(ctx, c.UserID, senderID, conv)
}
s.pushTo(ctx, conv, c.UserID, title, body)
}
}
// pushTo delivers one chat notification, applying the suppression rules every
// chat push obeys: the Messages preference, a live connection, and the
// per-thread debounce. Reactions go through here too, so they cannot become a
// way around any of them.
//
// Mute is checked by callers, since they already hold the participant row.
func (s *ChatService) pushTo(ctx context.Context, conv *model.Conversation, userID, title, body string) {
if s.push == nil || !s.push.Enabled() {
return
}
if s.prefs != nil { if s.prefs != nil {
if off, err := s.prefs.DisabledSet(ctx, c.UserID); err == nil && if off, err := s.prefs.DisabledSet(ctx, userID); err == nil &&
prefOff(off, NotifChatMessage, repository.ChannelPush) { prefOff(off, NotifChatMessage, repository.ChannelPush) {
continue return
} }
} }
if s.online(ctx, c.UserID) { if s.online(ctx, userID) {
continue return
} }
if !s.claimPushSlot(ctx, c.UserID, conv.ID) { if !s.claimPushSlot(ctx, userID, conv.ID) {
continue return
} }
s.push.SendToUser(ctx, c.UserID, PushPayload{ s.push.SendToUser(ctx, userID, PushPayload{
Title: title, Title: title,
Body: body, Body: body,
URL: "/chats/" + conv.ID, URL: "/chats/" + conv.ID,
// Same tag per thread: a later message replaces the earlier // Same tag per thread: a later notification replaces the earlier one
// notification rather than stacking up. // rather than stacking up.
Tag: "chat:" + conv.ID, Tag: "chat:" + conv.ID,
}) })
} }
// emailFirstContact tells someone a conversation has been opened with them,
// when nothing else would reach them.
//
// Deliberately says nothing about the message itself: an email subject line is
// the least private surface in the product, and "you have a message" is enough
// to bring someone back.
func (s *ChatService) emailFirstContact(ctx context.Context, recipientID, senderID string, conv *model.Conversation) {
if s.mail == nil || s.users == nil {
return
} }
// The same preference as chat push, on its email channel — so turning
// Messages off silences both rather than leaving one leaking through.
if s.prefs != nil {
if off, err := s.prefs.DisabledSet(ctx, recipientID); err == nil &&
prefOff(off, NotifChatMessage, repository.ChannelEmail) {
return
}
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil || recipient == nil ||
recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// OAuth-only accounts may have no address on file.
return
}
sender, err := s.users.FindByID(ctx, senderID)
if err != nil || sender == nil {
return
}
var buf bytes.Buffer
if err := firstContactEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"SenderName": actorDisplay(sender),
"SenderHandle": sender.Handle,
"ChatURL": s.frontendURL + "/chats/" + conv.ID,
}); err != nil {
s.logger.Warn("chat: first-contact email render failed", zap.Error(err))
return
}
subject := actorDisplay(sender) + " sent you a message — Prabhupāda Says"
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
s.logger.Warn("chat: first-contact email failed",
zap.String("recipient", recipientID), zap.Error(err))
}
}
// Styled to match the follow-request email so the two read as one product.
var firstContactEmailTpl = template.Must(template.New("firstContact").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><strong>{{.SenderName}}</strong> (@{{.SenderHandle}}) has sent you a message.
Reply to them on Prabhupāda Says.</p>
<p style="margin:1.6em 0">
<a href="{{.ChatURL}}" style="background:#9a6b2f;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;display:inline-block">Open the conversation</a>
</p>
<p style="font-size:14px;color:#7a6a55">You are receiving this because someone started a conversation with you.
You can turn these off under Settings → Notifications.</p>
</div>`))
// pushCopy builds the lock-screen text. // pushCopy builds the lock-screen text.
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) { func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
+500 -2
View File
@@ -44,7 +44,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit
// no-ops — these tests are about the rules, and none of those services are // no-ops — these tests are about the rules, and none of those services are
// reachable from a test run. // reachable from a test run.
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg), return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
nil, nil, nil, nil, nil, nil, zap.NewNop()), chat, pg nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()), chat, pg
} }
// chatUserWithHandle returns both identifiers: ids for authoring, handles for // chatUserWithHandle returns both identifiers: ids for authoring, handles for
@@ -1928,7 +1928,7 @@ func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
pg := &repository.Postgres{Pool: pool} pg := &repository.Postgres{Pool: pool}
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg), svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
repository.NewPostRepo(pg), nil, nil, nil, nil, nil, nil, zap.NewNop()) repository.NewPostRepo(pg), nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop())
// measure returns the statement count for reading a thread carrying // measure returns the statement count for reading a thread carrying
// `shares` distinct shared posts. // `shares` distinct shared posts.
@@ -2204,3 +2204,501 @@ func TestReceipts_TickProgression(t *testing.T) {
t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back) t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back)
} }
} }
func reactionsOf(t *testing.T, msgs []model.Message, id string) []model.Reaction {
t.Helper()
for i := range msgs {
if msgs[i].ID == id {
return msgs[i].Reactions
}
}
return nil
}
// Reacting again replaces rather than stacks, and the chip knows whose it is.
func TestReactions_ReplaceAndToggle(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hare kṛṣṇa"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, b, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
msgs, _ := svc.Messages(ctx, b, conv.ID, 0, 10)
got := reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Emoji != HeartEmoji || got[0].Count != 1 || !got[0].Mine {
t.Fatalf("after reacting: %+v", got)
}
// The author sees the same reaction, but it is not theirs.
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Mine {
t.Errorf("author's view: %+v — Mine should be false", got)
}
// Changing your mind replaces; it must not accumulate.
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
t.Fatal(err)
}
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Emoji != "😂" {
t.Errorf("after changing: %+v — want exactly one, the new emoji", got)
}
// Removing leaves nothing, and removing twice is harmless.
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
t.Fatal(err)
}
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
t.Errorf("second unreact should be a no-op, got %v", err)
}
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
if got = reactionsOf(t, msgs, msg.ID); len(got) != 0 {
t.Errorf("after removing: %+v — want none", got)
}
}
// Counts group across people, and each viewer sees only their own as "mine".
func TestReactions_GroupAcrossPeople(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
if err != nil {
t.Fatal(err)
}
for _, u := range []string{a, b} {
if err := svc.React(ctx, u, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
}
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
got := reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Count != 2 || !got[0].Mine {
t.Errorf("A's view: %+v — want one chip, count 2, mine", got)
}
msgs, _ = svc.Messages(ctx, owner, conv.ID, 0, 10)
got = reactionsOf(t, msgs, msg.ID)
if len(got) != 1 || got[0].Count != 2 || got[0].Mine {
t.Errorf("owner's view: %+v — count 2 but not mine", got)
}
}
// A message id alone must not let an outsider react into a thread they were
// never part of.
func TestReactions_OutsiderRefused(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "private"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, outsider, msg.ID, HeartEmoji); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider react: err = %v, want ErrNotParticipant", err)
}
if err := svc.Typing(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider typing: err = %v, want ErrNotParticipant", err)
}
}
// An emoji column is not free text storage.
func TestReactions_RejectsJunk(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "x"})
if err != nil {
t.Fatal(err)
}
for _, bad := range []string{"", " ", strings.Repeat("a", 64)} {
if err := svc.React(ctx, b, msg.ID, bad); !errors.Is(err, ErrChatBadEmoji) {
t.Errorf("React(%q): err = %v, want ErrChatBadEmoji", bad, err)
}
}
}
// The "who reacted" list names each person and their emoji, and is readable
// only by people in the thread.
func TestReactionDetails_ListsEveryoneAndGuardsOutsiders(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
outsider := chatUser(t, pg, "X")
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
if err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, a, msg.ID, HeartEmoji); err != nil {
t.Fatal(err)
}
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
t.Fatal(err)
}
list, err := svc.MessageReactions(ctx, owner, msg.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("list = %d entries, want 2", len(list))
}
byUser := map[string]string{}
for _, d := range list {
byUser[d.User.ID] = d.Emoji
if d.User.Handle == "" || d.User.Name == "" {
t.Errorf("entry %+v is missing the details needed to render a row", d)
}
}
if byUser[a] != HeartEmoji || byUser[b] != "😂" {
t.Errorf("emoji did not follow their authors: %+v", byUser)
}
// Changing your mind replaces your entry rather than adding one.
if err := svc.React(ctx, a, msg.ID, "🙏"); err != nil {
t.Fatal(err)
}
list, _ = svc.MessageReactions(ctx, owner, msg.ID)
if len(list) != 2 {
t.Errorf("after changing: %d entries, want 2", len(list))
}
// Knowing a message id must not reveal who is in a thread you're not in.
if _, err := svc.MessageReactions(ctx, outsider, msg.ID); !errors.Is(err, repository.ErrNotParticipant) {
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
}
}
// A reply must arrive with its quoted preview already attached. Append only
// RETURNINGs reply_to_id, so without a read-back the sender — and everyone
// receiving the realtime event — would see a reply quoting nothing until the
// thread was reloaded.
func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"})
if err != nil {
t.Fatal(err)
}
reply, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "answering", ReplyToID: &original.ID,
})
if err != nil {
t.Fatal(err)
}
if reply.ReplyTo == nil {
t.Fatal("the send response has no quoted preview — the client has nothing to render")
}
if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" {
t.Errorf("quote = %+v, want the original message", reply.ReplyTo)
}
// And a retried send must return the same shape, not a bare row.
clientID := uuid.NewString()
first, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
})
if err != nil {
t.Fatal(err)
}
retry, err := svc.Send(ctx, b, conv.ID, SendRequest{
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
})
if err != nil {
t.Fatal(err)
}
if retry.ID != first.ID {
t.Fatalf("retry created a second message: %s vs %s", retry.ID, first.ID)
}
if retry.ReplyTo == nil {
t.Error("a retried reply lost its quote")
}
}
// grantSticker gives a user a reward the way sādhana does, returning its key.
func grantSticker(t *testing.T, pg *repository.Postgres, userID, key string) string {
t.Helper()
ctx := context.Background()
var rewardID string
if err := pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, $2, 'https://example.invalid/art.png')
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
RETURNING id`, key, "Sticker "+key).Scan(&rewardID); err != nil {
t.Fatalf("create reward: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(ctx, `DELETE FROM sadhna_rewards WHERE id = $1`, rewardID)
})
if userID != "" {
// milestone is unique per user; a large random-ish one avoids clashing
// with any real streak rows.
if _, err := pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
userID, rewardID, 900000+len(key)); err != nil {
t.Fatalf("grant reward: %v", err)
}
}
return key
}
func stickerAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
t.Helper()
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentSticker {
return &msgs[i].Attachments[j]
}
}
}
return nil
}
// A sticker key is not a capability: sending one you never earned must fail,
// or the whole catalog is available to anyone who can guess a key.
func TestStickers_OnlySendWhatYouOwn(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
mine := grantSticker(t, pg, a, "sticker_owned_"+a[:8])
// Exists in the catalog, granted to nobody.
unearned := grantSticker(t, pg, "", "sticker_unearned_"+a[:8])
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &mine}); err != nil {
t.Fatalf("sending an earned sticker: %v", err)
}
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
att := stickerAttachment(t, msgs)
if att == nil {
t.Fatal("an earned sticker did not attach")
}
if att.ImageURL == nil || *att.ImageURL == "" || att.EntityID == nil || *att.EntityID != mine {
t.Errorf("attachment = %+v, want the artwork and its key", att)
}
// Not earned, not saved: the message sends, but carries no sticker.
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "trying", StickerKey: &unearned,
}); err != nil {
t.Fatal(err)
}
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
for i := range msgs {
for j := range msgs[i].Attachments {
if att := msgs[i].Attachments[j]; att.Kind == model.AttachmentSticker &&
att.EntityID != nil && *att.EntityID == unearned {
t.Error("a sticker the sender never earned was attached")
}
}
}
}
// Saving is how a sticker spreads — but only from a message actually sent to
// you, in a thread you are actually in.
func TestStickers_SaveFromChat(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
key := grantSticker(t, pg, a, "sticker_shared_"+a[:8])
sent, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &key})
if err != nil {
t.Fatal(err)
}
// B has not earned it, so it isn't in their drawer yet.
before, _ := svc.Stickers(ctx, b)
for _, s := range before {
if s.Key == key {
t.Fatal("B has the sticker before saving it")
}
}
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
t.Fatalf("saving a sticker sent to you: %v", err)
}
after, _ := svc.Stickers(ctx, b)
var found *model.Sticker
for i := range after {
if after[i].Key == key {
found = &after[i]
}
}
if found == nil {
t.Fatal("saved sticker is not in B's drawer")
}
if !found.Saved {
t.Error("a sticker from a chat should be marked as saved, not earned")
}
// B can now send it themselves.
if _, err := svc.Send(ctx, b, conv.ID, SendRequest{StickerKey: &key}); err != nil {
t.Errorf("B should be able to send a saved sticker: %v", err)
}
// Saving twice is harmless.
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
t.Errorf("second save should be idempotent: %v", err)
}
// Someone outside the thread cannot save from it, even knowing both ids.
if err := svc.SaveSticker(ctx, outsider, sent.ID, key); !errors.Is(err, repository.ErrStickerNotOwned) {
t.Errorf("outsider save: err = %v, want ErrStickerNotOwned", err)
}
// The sender's own drawer still reports it as earned, not saved.
mine, _ := svc.Stickers(ctx, a)
for _, s := range mine {
if s.Key == key && s.Saved {
t.Error("an earned sticker is being reported as saved")
}
}
}
// A quote, sticker, photo or shared post lives in an attachment row, not in
// the body — so forwarding one has to carry the attachments across or the
// recipient gets an empty bubble.
func TestForward_CarriesAttachments(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
source, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, source.ID)
target, err := svc.OpenDM(ctx, a, c)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, target.ID)
// A quote with no body at all — the case that forwarded blank.
sent, err := svc.Send(ctx, a, source.ID, SendRequest{
Quote: &QuoteAttachment{
Path: "/books/bg/2/13?hl=abc",
Title: "Bhagavad-gītā 2.13",
Text: "As the embodied soul continually passes…",
},
})
if err != nil {
t.Fatal(err)
}
if sent.Body != "" {
t.Fatalf("setup: expected a body-less quote, got %q", sent.Body)
}
fwd, err := svc.ForwardMessage(ctx, a, sent.ID, target.ID)
if err != nil {
t.Fatal(err)
}
msgs, err := svc.Messages(ctx, c, target.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
var quote *model.Attachment
for i := range msgs {
if msgs[i].ID != fwd.ID {
continue
}
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentQuote {
quote = &msgs[i].Attachments[j]
}
}
}
if quote == nil {
t.Fatal("the forwarded message has no quote — it would render empty")
}
if quote.Description == nil || *quote.Description == "" {
t.Error("the forwarded quote lost its passage")
}
if quote.URL == nil || *quote.URL != "/books/bg/2/13?hl=abc" {
t.Errorf("the forwarded quote lost its deep link: %v", quote.URL)
}
// The copy belongs to the DESTINATION thread: reads authorize against the
// attachment's own conversation, so a copy still scoped to the source
// would be unreadable to the recipient.
var convID string
if err := pg.Pool.QueryRow(ctx,
`SELECT conversation_id FROM message_attachments WHERE message_id = $1`,
fwd.ID).Scan(&convID); err != nil {
t.Fatal(err)
}
if convID != target.ID {
t.Errorf("copied attachment is scoped to %s, want the destination %s", convID, target.ID)
}
// The original is untouched.
back, err := svc.Messages(ctx, b, source.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
found := false
for i := range back {
if back[i].ID == sent.ID && len(back[i].Attachments) == 1 {
found = true
}
}
if !found {
t.Error("forwarding disturbed the original message's attachment")
}
}
@@ -0,0 +1,124 @@
package service
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
const (
maxCategoryLabel = 60
maxCategoryDescription = 200
)
var (
ErrCategoryLabel = fmt.Errorf("a category needs a name (up to %d characters)", maxCategoryLabel)
ErrCategoryDescription = fmt.Errorf("the description can be up to %d characters", maxCategoryDescription)
ErrCategoryLastActive = errors.New("the feedback form needs at least one category")
)
var categoryNonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// FeedbackCategoryService manages the /feedback form's categories.
type FeedbackCategoryService struct {
repo *repository.FeedbackCategoryRepo
}
func NewFeedbackCategoryService(repo *repository.FeedbackCategoryRepo) *FeedbackCategoryService {
return &FeedbackCategoryService{repo: repo}
}
// Active is what the /feedback form offers, in order.
func (s *FeedbackCategoryService) Active(ctx context.Context) ([]model.FeedbackCategory, error) {
return s.repo.List(ctx, false)
}
// All is the admin's view: archived ones too, with usage counts.
func (s *FeedbackCategoryService) All(ctx context.Context) ([]model.FeedbackCategory, error) {
return s.repo.List(ctx, true)
}
func cleanCategory(label, description string) (string, string, error) {
label = strings.TrimSpace(label)
description = strings.TrimSpace(description)
if label == "" || len([]rune(label)) > maxCategoryLabel {
return "", "", ErrCategoryLabel
}
if len([]rune(description)) > maxCategoryDescription {
return "", "", ErrCategoryDescription
}
return label, description, nil
}
// Create adds a category to the end of the form. Its key is derived from
// the name ("Audio problem" → audio_problem) and fixed from then on, so it
// can be renamed freely later without touching past feedback.
func (s *FeedbackCategoryService) Create(ctx context.Context, label, description string) (*model.FeedbackCategory, error) {
label, description, err := cleanCategory(label, description)
if err != nil {
return nil, err
}
base := strings.Trim(categoryNonSlug.ReplaceAllString(strings.ToLower(label), "_"), "_")
if base == "" {
base = "category"
}
if len(base) > 40 {
base = strings.TrimRight(base[:40], "_")
}
key := base
for i := 2; ; i++ {
taken, err := s.repo.KeyTaken(ctx, key)
if err != nil {
return nil, err
}
if !taken {
break
}
key = fmt.Sprintf("%s_%d", base, i)
}
return s.repo.Create(ctx, key, label, description)
}
// Update renames, rewords, archives or restores a category. The last active
// category can't be archived — the form would have nothing to offer.
func (s *FeedbackCategoryService) Update(ctx context.Context, key, label, description string, archived bool) error {
label, description, err := cleanCategory(label, description)
if err != nil {
return err
}
if archived {
if err := s.guardLastActive(ctx, key); err != nil {
return err
}
}
return s.repo.Update(ctx, key, label, description, archived)
}
// Delete removes a category nothing is filed under (otherwise: archive it).
func (s *FeedbackCategoryService) Delete(ctx context.Context, key string) error {
if err := s.guardLastActive(ctx, key); err != nil {
return err
}
return s.repo.Delete(ctx, key)
}
// Reorder sets the form's order.
func (s *FeedbackCategoryService) Reorder(ctx context.Context, keys []string) error {
return s.repo.Reorder(ctx, keys)
}
func (s *FeedbackCategoryService) guardLastActive(ctx context.Context, key string) error {
active, err := s.repo.List(ctx, false)
if err != nil {
return err
}
if len(active) == 1 && active[0].Key == key {
return ErrCategoryLastActive
}
return nil
}
@@ -0,0 +1,106 @@
package service
import (
"context"
"errors"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// Categories are managed from the admin: a new one is offered on the form
// and accepts feedback; renaming relabels past feedback; archiving stops new
// submissions but keeps the label; in-use categories can't be deleted.
func TestFeedbackCategories(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
repo := repository.NewFeedbackCategoryRepo(pg)
cats := NewFeedbackCategoryService(repo)
fb := NewFeedbackService(repository.NewFeedbackRepo(pg), repo, nil, nil, zap.NewNop())
user := chatUser(t, pg, "Feedback")
t.Cleanup(func() {
bg := context.Background()
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback WHERE user_id = $1`, user)
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback_categories WHERE key LIKE 'zz_qa_audio%'`)
})
// The seeded categories production already uses are all on the form.
active, err := cats.Active(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, c := range active {
seen[c.Key] = true
}
for _, k := range []string{"general", "bug", "feature", "other"} {
if !seen[k] {
t.Errorf("seeded category %q missing from the form", k)
}
}
// Keys come from the name, and never collide.
c1, err := cats.Create(ctx, "ZZ QA Audio problem", "Playback trouble")
if err != nil {
t.Fatal(err)
}
c2, err := cats.Create(ctx, "ZZ QA audio problem!", "")
if err != nil {
t.Fatal(err)
}
if c1.Key != "zz_qa_audio_problem" || c2.Key != "zz_qa_audio_problem_2" {
t.Errorf("keys = %q, %q", c1.Key, c2.Key)
}
if _, err := cats.Create(ctx, " ", ""); !errors.Is(err, ErrCategoryLabel) {
t.Errorf("blank name: %v, want ErrCategoryLabel", err)
}
// New categories take feedback; unknown ones don't.
if _, err := fb.Submit(ctx, &user, c1.Key, "the audio stutters", nil); err != nil {
t.Fatalf("submit in a new category: %v", err)
}
if _, err := fb.Submit(ctx, &user, "no_such_category", "x", nil); !errors.Is(err, ErrFeedbackCategory) {
t.Errorf("unknown category: %v, want ErrFeedbackCategory", err)
}
// Renaming relabels what's already been filed.
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "Playback trouble", false); err != nil {
t.Fatal(err)
}
mine, _ := fb.ListForUser(ctx, user, 10)
if len(mine) != 1 || mine[0].CategoryLabel != "ZZ QA Audio & playback" || mine[0].Category != c1.Key {
t.Errorf("past feedback after rename = %+v", mine)
}
// Archived: off the form, refuses new feedback, keeps its label.
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "", true); err != nil {
t.Fatal(err)
}
if _, err := fb.Submit(ctx, &user, c1.Key, "again", nil); !errors.Is(err, ErrFeedbackCategory) {
t.Errorf("submit to an archived category: %v, want ErrFeedbackCategory", err)
}
active, _ = cats.Active(ctx)
for _, c := range active {
if c.Key == c1.Key {
t.Error("archived category still on the form")
}
}
if mine, _ := fb.ListForUser(ctx, user, 10); mine[0].CategoryLabel != "ZZ QA Audio & playback" {
t.Error("archiving lost the label on past feedback")
}
all, _ := cats.All(ctx)
for _, c := range all {
if c.Key == c1.Key && (c.ArchivedAt == nil || c.UsageCount != 1) {
t.Errorf("admin view of archived category = %+v", c)
}
}
// In use → can't be deleted; unused → can.
if err := cats.Delete(ctx, c1.Key); !errors.Is(err, repository.ErrFeedbackCategoryInUse) {
t.Errorf("deleting an in-use category: %v, want ErrFeedbackCategoryInUse", err)
}
if err := cats.Delete(ctx, c2.Key); err != nil {
t.Errorf("deleting an unused category: %v", err)
}
}
+25 -17
View File
@@ -22,23 +22,22 @@ import (
type FeedbackService struct { type FeedbackService struct {
repo *repository.FeedbackRepo repo *repository.FeedbackRepo
// categories is the admin-managed list a submission must belong to.
categories *repository.FeedbackCategoryRepo
mail mailer.Mailer mail mailer.Mailer
admins []string admins []string
logger *zap.Logger logger *zap.Logger
} }
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService { func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger} return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger}
} }
// Allowed categories — kept lowercase in storage so dashboards group // Allowed categories — kept lowercase in storage so dashboards group
// cleanly. The frontend dropdown sends one of these exact strings. // cleanly. The frontend dropdown sends one of these exact strings.
var feedbackCategories = map[string]bool{ // ErrFeedbackCategory: the submission names a category that doesn't exist
"bug": true, // or has been archived in the admin panel.
"feature": true, var ErrFeedbackCategory = errors.New("invalid category")
"general": true,
"other": true,
}
// Submit persists a feedback row and fires the admin email. // Submit persists a feedback row and fires the admin email.
// //
@@ -49,8 +48,10 @@ var feedbackCategories = map[string]bool{
// trustworthy (the form can't lie about who's writing). // trustworthy (the form can't lie about who's writing).
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) { func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
category = strings.ToLower(strings.TrimSpace(category)) category = strings.ToLower(strings.TrimSpace(category))
if !feedbackCategories[category] { if ok, err := s.categories.IsActive(ctx, category); err != nil {
return nil, errors.New("invalid category") return nil, err
} else if !ok {
return nil, ErrFeedbackCategory
} }
message = strings.TrimSpace(message) message = strings.TrimSpace(message)
if message == "" { if message == "" {
@@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
return nil, err return nil, err
} }
// Hydrate the joined author fields for the email template. Failing // Hydrate the joined author fields and category label for the email
// the lookup isn't worth failing the request — we fall back to // template. Failing the lookup isn't worth failing the request — we fall
// "anonymous" rather than 500ing on the user. // back to "anonymous" and the category key rather than 500ing.
if userID != nil {
if full, err := s.repo.FindByID(ctx, f.ID); err == nil { if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full f = full
} else { } else {
s.logger.Warn("feedback hydrate failed; sending email without author info", s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err)) zap.String("feedback_id", f.ID), zap.Error(err))
} f.CategoryLabel = f.Category
} }
s.notifyAdmins(f) s.notifyAdmins(f)
@@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
s.logger.Warn("render feedback email failed", zap.Error(err)) s.logger.Warn("render feedback email failed", zap.Error(err))
return return
} }
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60)) subject := fmt.Sprintf("[Feedback · %s] %s", categoryLabel(f), summarize(f.Message, 60))
for _, to := range s.admins { for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil { if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("feedback email send failed", s.logger.Warn("feedback email send failed",
@@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
} }
var buf bytes.Buffer var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{ err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category, "Category": categoryLabel(f),
"Message": f.Message, "Message": f.Message,
"From": from, "From": from,
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"), "CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
@@ -360,3 +360,11 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
} }
return buf.String(), nil return buf.String(), nil
} }
// categoryLabel is the category's display name, or its key if unknown.
func categoryLabel(f *model.Feedback) string {
if f.CategoryLabel != "" {
return f.CategoryLabel
}
return f.Category
}
+260
View File
@@ -0,0 +1,260 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return repository.NewRewardRepo(pg), pg
}
func giftUser(t *testing.T, pg *repository.Postgres) string {
t.Helper()
id := uuid.NewString()
suffix := id[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', 'Gift User', $3)`,
id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
func giftReward(t *testing.T, pg *repository.Postgres) string {
t.Helper()
key := "gift_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil {
t.Fatalf("create reward: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key)
})
return key
}
// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting
// must not duplicate it.
func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
key := giftReward(t, pg)
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
find := func() (found bool, opened bool) {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
for _, g := range grants {
if g.Reward.Key == key {
if g.Source != "gift" {
t.Errorf("source = %q, want gift", g.Source)
}
if g.Milestone != nil {
t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone)
}
return true, g.Opened
}
}
return false, false
}
found, opened := find()
if !found {
t.Fatal("the gift did not reach an existing account")
}
if opened {
t.Error("a new gift should arrive wrapped")
}
// It must NOT hijack the streak confetti — that stays for streak rewards.
if pending, _ := rewards.PendingGrant(ctx, user); pending != nil {
t.Errorf("a gift appeared as a pending streak celebration: %+v", pending)
}
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Fatal(err)
}
if _, opened = find(); !opened {
t.Error("opening the gift did not un-blur it")
}
// Opening twice is harmless.
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Errorf("second open should be a no-op: %v", err)
}
// Re-gifting tops up newcomers without duplicating anyone's tile.
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
grants, _ := rewards.GrantsByUser(ctx, user)
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
if n != 1 {
t.Errorf("holdings of the gift = %d, want 1", n)
}
// ...and it must not silently re-wrap what was already opened.
if _, opened = find(); !opened {
t.Error("re-gifting re-wrapped an already opened gift")
}
}
// Deleting a custom gift must clear it from every gallery it reached, and must
// refuse to touch the seeded catalog — a seeded reward would return on the
// next Seed run, after having been ripped out of people's galleries.
func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
// A custom reward, as the admin panel creates them.
key := "custom_probe_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(ctx, `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Probe Gift', 'https://example.invalid/p.png')`, key); err != nil {
t.Fatal(err)
}
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
held := func() int {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
return n
}
if held() != 1 {
t.Fatalf("setup: the gift did not reach the gallery")
}
if err := rewards.DeleteCustom(ctx, key); err != nil {
t.Fatal(err)
}
if held() != 0 {
t.Error("deleting the reward left it in a devotee's gallery")
}
if _, err := rewards.ByKey(ctx, key); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("reward still in the catalog: %v", err)
}
// Seeded rewards are off limits.
var seeded string
if err := pg.Pool.QueryRow(ctx,
`SELECT key FROM sadhna_rewards WHERE key NOT LIKE 'custom\_%' LIMIT 1`,
).Scan(&seeded); err != nil {
t.Skip("no seeded rewards to test against")
}
if err := rewards.DeleteCustom(ctx, seeded); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("a seeded reward was deletable: %v", err)
}
if _, err := rewards.ByKey(ctx, seeded); err != nil {
t.Errorf("the seeded reward was removed: %v", err)
}
}
// A suspended account must not be discoverable. One query backs the people
// search page, the mention autocomplete and the chat member pickers, so a leak
// here surfaces in all three.
func TestBannedUsers_AreNotDiscoverable(t *testing.T) {
_, pg := testRewardRepo(t)
ctx := context.Background()
users := repository.NewUserRepo(pg)
// A distinctive name so the search can't match anyone else.
marker := "Zzbanprobe" + uuid.NewString()[:6]
active := giftUser(t, pg)
banned := giftUser(t, pg)
for id, name := range map[string]string{active: marker + " Active", banned: marker + " Banned"} {
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET name = $2 WHERE id = $1`, id, name); err != nil {
t.Fatal(err)
}
}
if _, err := pg.Pool.Exec(ctx,
`UPDATE users SET banned_at = now() WHERE id = $1`, banned); err != nil {
t.Fatal(err)
}
found, total, err := users.Search(ctx, marker, 20, 0)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, u := range found {
ids[u.ID] = true
}
if !ids[active] {
t.Error("the active account is missing from search")
}
if ids[banned] {
t.Error("a suspended account is searchable — it can be @mentioned or added to a group")
}
// The count backs pagination, so it has to agree with the page.
if total != len(found) {
t.Errorf("total = %d but page returned %d — count and page WHERE disagree", total, len(found))
}
// And the welcome strip, which is the most prominent placement there is.
recent, err := users.RecentlyJoined(ctx, active, time.Now().Add(-24*time.Hour), 20)
if err != nil {
t.Fatal(err)
}
for _, u := range recent {
if u.ID == banned {
t.Error("a suspended account appears in the new-users strip")
}
}
}
+9 -6
View File
@@ -26,6 +26,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -65,8 +66,8 @@ var PrefCategories = map[string][]string{
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply}, "post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete}, "sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
"community": {NotifDiscipleJoined}, "community": {NotifDiscipleJoined},
// Push only: in-app delivery for chat is the inbox itself, and there is // Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
// no such thing as an emailed chat message. // In-app delivery for chat is the inbox itself, so it isn't offered here.
"messages": {NotifChatMessage}, "messages": {NotifChatMessage},
} }
@@ -300,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) { if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
s.push.SendToUser(ctx, authorID, PushPayload{ s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like", Title: "New Like",
Body: body, Body: richtext.Plain(body),
URL: url, URL: url,
Icon: s.iconURL(), Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID, Tag: NotifPostLike + ":" + postID,
@@ -642,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if s.rt != nil { if s.rt != nil {
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{ if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
"kind": "admin_message", "kind": "admin_message",
"body": body, "body": richtext.Plain(body),
}); err != nil { }); err != nil {
s.logger.Warn("realtime admin message publish failed", s.logger.Warn("realtime admin message publish failed",
zap.String("recipient", recipientID), zap.Error(err)) zap.String("recipient", recipientID), zap.Error(err))
@@ -670,7 +671,9 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{ if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name, "RecipientName": recipient.Name,
"Title": strings.TrimSpace(title), "Title": strings.TrimSpace(title),
"Body": body, // Rendered markers (bold, links…). Already escaped by richtext,
// and its <br>s replace the old white-space:pre-line.
"Body": richtext.ToHTML(body),
"URL": url, "URL": url,
}); err != nil { }); err != nil {
s.logger.Warn("render admin message email failed", zap.Error(err)) s.logger.Warn("render admin message email failed", zap.Error(err))
@@ -882,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<di
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}} {{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
<p style="white-space:pre-line">{{.Body}}</p> <p>{{.Body}}</p>
<p style="margin:1.25em 0"> <p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a> <a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
+422
View File
@@ -0,0 +1,422 @@
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, year *int, err error) {
p := NormalizeContentPath(raw)
if p == "" {
return model.QuizStandalone, nil, "", 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, "", nil, ErrQuizTarget
}
kind = model.QuizLecture
if t.Type == "conversation" {
kind = model.QuizConversation
}
title = t.Name
year = t.Year
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, "", nil, ErrQuizTarget
}
if o := strings.TrimSpace(override); o != "" {
title = o
}
return kind, &p, title, year, 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, in.TargetYear, 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
}
if err := s.ensureTargetFree(ctx, in, ""); 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.ensureTargetFree(ctx, in, id); err != nil {
return nil, err
}
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
return nil, err
}
return s.GetAdmin(ctx, id)
}
// ensureTargetFree refuses a second quiz on the same chapter, lecture or
// conversation, naming the one already there so the admin can edit it
// instead. Stand-alone quizzes are never limited. The unique index backs
// this up against a race between two saves.
func (s *QuizService) ensureTargetFree(ctx context.Context, in repository.QuizInput, selfID string) error {
if in.TargetPath == nil {
return nil
}
_, title, err := s.quizzes.QuizOnTarget(ctx, *in.TargetPath, selfID)
if err != nil {
return err
}
if title != "" {
return fmt.Errorf("%w — %q is already linked to %s. Edit that quiz, or delete it first",
repository.ErrQuizTargetTaken, title, *in.TargetPath)
}
return nil
}
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)
}
// ResetParticipant wipes one participant's attempt; the quiz becomes pending
// for them. Works on unpublished quizzes too — staff may tidy results up
// while a quiz is offline.
func (s *QuizService) ResetParticipant(ctx context.Context, quizID, userID string) error {
if _, err := s.quizzes.Get(ctx, quizID, false); err != nil {
return err
}
return s.quizzes.ResetAttempt(ctx, quizID, userID)
}
// ── Taking ────────────────────────────────────────────────────────────────
// published loads a quiz that users may see.
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)
}
+384
View File
@@ -0,0 +1,384 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"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)
}
// Staff can reset one participant: their attempt and answers go, the quiz
// is pending for them again, and they can retake it from the start.
if err := svc.ResetParticipant(ctx, quiz.ID, other); !errors.Is(err, repository.ErrQuizNotStarted) {
t.Errorf("resetting someone with no attempt: %v, want ErrQuizNotStarted", err)
}
if err := svc.ResetParticipant(ctx, quiz.ID, user); err != nil {
t.Fatal(err)
}
if card, _ := svc.Card(ctx, user, quiz.ID); card.My.State != model.QuizNotStarted {
t.Errorf("state after reset = %s, want not_started", card.My.State)
}
if parts, _ := svc.Participants(ctx, quiz.ID); len(parts) != 0 {
t.Errorf("participants after reset = %d, want 0", len(parts))
}
if again, err := svc.Start(ctx, user, quiz.ID); err != nil || again.Answered != 0 || again.Question == nil {
t.Errorf("retaking after reset: %+v, %v", again, err)
}
// Taken, so questions are frozen — but the title can still change.
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
}
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)
}
}
}
// A chapter, lecture or conversation carries at most one live quiz. The
// service names the quiz already there; the unique index catches a race;
// deleting a quiz frees its link; stand-alone quizzes are unlimited.
func TestQuiz_OnePerTarget(t *testing.T) {
svc, pg := newQuizSvc(t)
ctx := context.Background()
admin := chatUser(t, pg, "Admin")
repo := repository.NewQuizRepo(pg)
path := "/books/zz-test/" + uuid.NewString()[:8]
in := repository.QuizInput{
Title: "First", TargetKind: model.QuizChapter, TargetPath: &path,
Questions: []repository.QuestionInput{q("p", 0, "a", "b")},
}
first, err := repo.Create(ctx, in, admin)
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, first)
// The friendly pre-check names the quiz that holds the link…
err = svc.ensureTargetFree(ctx, in, "")
if !errors.Is(err, repository.ErrQuizTargetTaken) || !strings.Contains(err.Error(), `"First"`) {
t.Errorf("second quiz on a link: %v, want ErrQuizTargetTaken naming \"First\"", err)
}
// …but a quiz re-saving its own link is fine.
if err := svc.ensureTargetFree(ctx, in, first); err != nil {
t.Errorf("re-saving the same quiz: %v", err)
}
// The index refuses it even if the check is skipped (two saves racing).
in.Title = "Second"
if _, err := repo.Create(ctx, in, admin); !errors.Is(err, repository.ErrQuizTargetTaken) {
t.Errorf("create past the index: %v, want ErrQuizTargetTaken", err)
}
// Moving another quiz onto the link is refused too.
other := repository.QuizInput{Title: "Other", TargetKind: model.QuizStandalone,
Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}
otherID, err := repo.Create(ctx, other, admin)
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, otherID)
other.TargetKind, other.TargetPath = model.QuizChapter, &path
if err := repo.Update(ctx, otherID, other, false); !errors.Is(err, repository.ErrQuizTargetTaken) {
t.Errorf("moving a quiz onto a taken link: %v, want ErrQuizTargetTaken", err)
}
// Stand-alone quizzes are unlimited.
if solo, err := svc.Create(ctx, admin, QuizDraft{Title: "Solo", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); err != nil {
t.Errorf("a second stand-alone quiz: %v", err)
} else {
cleanupQuiz(t, pg, solo.ID)
}
// Deleting the first frees the link.
if err := svc.Delete(ctx, first); err != nil {
t.Fatal(err)
}
if err := repo.Update(ctx, otherID, other, false); err != nil {
t.Errorf("taking a freed link: %v", err)
}
}
+94 -1
View File
@@ -16,7 +16,12 @@ package service
import ( import (
"context" "context"
crand "crypto/rand"
"encoding/hex"
"errors"
"math/rand" "math/rand"
"regexp"
"strings"
"time" "time"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
@@ -64,6 +69,9 @@ type SadhnaService struct {
posts *repository.PostRepo posts *repository.PostRepo
users *repository.UserRepo users *repository.UserRepo
rewards *repository.RewardRepo rewards *repository.RewardRepo
// r2 stores custom gift artwork. Optional: nil disables custom gifts
// rather than failing anything that already works.
r2 *repository.R2
logger *zap.Logger logger *zap.Logger
} }
@@ -72,9 +80,13 @@ func NewSadhnaService(
posts *repository.PostRepo, posts *repository.PostRepo,
users *repository.UserRepo, users *repository.UserRepo,
rewards *repository.RewardRepo, rewards *repository.RewardRepo,
r2 *repository.R2,
logger *zap.Logger, logger *zap.Logger,
) *SadhnaService { ) *SadhnaService {
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger} return &SadhnaService{
logs: logs, posts: posts, users: users, rewards: rewards,
r2: r2, logger: logger,
}
} }
const dayFmt = "2006-01-02" const dayFmt = "2006-01-02"
@@ -398,6 +410,87 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad
// AckRewards clears the user's pending celebration(s) after the client has // AckRewards clears the user's pending celebration(s) after the client has
// shown the confetti. // shown the confetti.
// OpenGift un-blurs one gift after the devotee has seen its celebration.
//
// Per gift, not per user: someone with two waiting should open them one at a
// time rather than have the first dismissal clear both.
func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error {
return s.rewards.OpenGift(ctx, userID, rewardKey)
}
// GiftToEveryone hands a reward to every account. Returns how many it reached.
func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) {
reward, err := s.rewards.ByKey(ctx, rewardKey)
if err != nil {
return 0, err
}
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
}
// customKeyPrefix namespaces admin-made rewards away from the seeded catalog,
// so Seed can never collide with one.
const customKeyPrefix = "custom_"
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// customRewardKey derives a stable, unique key from a name.
//
// The random suffix is what makes it unique: two gifts may reasonably share a
// name, and a collision would overwrite the first one's artwork.
func customRewardKey(name string) (string, error) {
slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_")
if slug == "" {
slug = "gift"
}
if len(slug) > 32 {
slug = slug[:32]
}
suffix := make([]byte, 4)
if _, err := crand.Read(suffix); err != nil {
return "", err
}
return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil
}
// CreateCustomGift adds a new sticker to the catalog. It is not granted to
// anyone until it is sent.
func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("a gift needs a name")
}
if strings.TrimSpace(imageURL) == "" {
return nil, errors.New("a gift needs an image")
}
key, err := customRewardKey(name)
if err != nil {
return nil, err
}
return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL)
}
// PresignGiftImage issues an upload URL for custom sticker artwork.
//
// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an
// absolute address, and stickers render for every participant in a chat, so
// the artwork belongs in the public bucket alongside avatars and post images.
func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType)
}
// DeleteCustomGift removes a custom sticker everywhere it is held.
func (s *SadhnaService) DeleteCustomGift(ctx context.Context, key string) error {
if !strings.HasPrefix(key, customKeyPrefix) {
return errors.New("only custom gifts can be deleted")
}
return s.rewards.DeleteCustom(ctx, key)
}
// GiftableRewards is the catalog an admin picks from.
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
return s.rewards.ListGiftable(ctx)
}
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error { func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
return s.rewards.MarkCelebrated(ctx, userID) return s.rewards.MarkCelebrated(ctx, userID)
} }
+23
View File
@@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp
// presignImage builds an R2 presign for a user-scoped media key under the // presignImage builds an R2 presign for a user-scoped media key under the
// given prefix. The random suffix invalidates browser caches when the image // given prefix. The random suffix invalidates browser caches when the image
// is replaced. // is replaced.
// presignImageKey is presignImage's sibling for callers that store the storage
// KEY rather than a URL — so the CDN domain can change without rewriting rows.
// Handing those callers a public URL silently doubles it at read time.
func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) {
if r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
ext, ok := imageExtensionFor(contentType)
if !ok {
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", "", err
}
key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext)
uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute)
if err != nil {
return "", "", err
}
return uploadURL, key, nil
}
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) { func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
if r2 == nil { if r2 == nil {
return "", "", errors.New("media uploads are not configured") return "", "", errors.New("media uploads are not configured")
+65
View File
@@ -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>`))
+256
View File
@@ -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)
}
+181
View File
@@ -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):
}
}
+140
View File
@@ -6,9 +6,12 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -31,6 +34,13 @@ func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService
// Today returns the current day's pick, choosing (and persisting) one on the // Today returns the current day's pick, choosing (and persisting) one on the
// first call of the day. Returns (nil, nil) only if there's genuinely no // first call of the day. Returns (nil, nil) only if there's genuinely no
// content to pick. // content to pick.
var (
// ErrVaniBadKind is a kind outside verse/prose/transcript.
ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
)
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) { func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
now := time.Now().UTC() now := time.Now().UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
@@ -75,6 +85,136 @@ func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
// choose tries the three content kinds in random order and returns the first // choose tries the three content kinds in random order and returns the first
// successful sample, so each day's pick varies across content types. // successful sample, so each day's pick varies across content types.
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
//
// The intended admin workflow: paste the link to the thing you want. Search
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
// matches on "london" and returns unrelated lectures.
//
// Accepts an absolute URL or a path, and understands the three shapes the
// vāṇī can point at:
//
// /transcriptions/{slug}
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
// /books/{book}/{slug} (prose chapter)
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
day = day.UTC().Truncate(24 * time.Hour)
path := strings.TrimSpace(raw)
if u, err := url.Parse(path); err == nil && u.Host != "" {
path = u.Path
}
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, ErrVaniBadURL
}
switch parts[0] {
case "transcriptions":
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
if err != nil {
return nil, err
}
return transcriptPick(t, day), nil
case "books":
book := parts[1]
nums := parts[2:]
// A trailing division segment (SB cantos, CC līlās) shifts the
// chapter/verse pair along by one.
var division *string
if len(nums) == 3 {
d := nums[0]
division, nums = &d, nums[1:]
}
if len(nums) == 2 {
chapter, err1 := strconv.Atoi(nums[0])
verse, err2 := strconv.Atoi(nums[1])
if err1 == nil && err2 == nil {
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
if err != nil {
return nil, err
}
return versePick(v, day), nil
}
}
// Not numeric, so it is a prose chapter's bare slug.
if len(nums) == 1 {
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
if err != nil {
return nil, err
}
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
}
}
return nil, ErrVaniBadURL
}
// SetByURL resolves a URL and pins the result.
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
pick, err := s.ResolveURL(ctx, raw, day)
if err != nil {
return nil, err
}
if err := s.repo.Upsert(ctx, pick); err != nil {
return nil, err
}
return pick, nil
}
// SetForDay pins a specific piece of content as a given day's Vāṇī.
//
// Takes only a kind and a content id — never a payload. The display blob is
// rebuilt here with the same builders the random picker uses, so a pinned pick
// is indistinguishable from an automatic one and a client cannot dictate what
// the home page says.
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
day = day.UTC().Truncate(24 * time.Hour)
var pick *model.VaniPick
switch kind {
case "verse":
v, err := s.repo.VerseByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = versePick(v, day)
case "prose":
p, err := s.repo.ProseByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
case "transcript":
t, err := s.repo.TranscriptByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = transcriptPick(t, day)
default:
return nil, ErrVaniBadKind
}
if err := s.repo.Upsert(ctx, pick); err != nil {
return nil, err
}
return pick, nil
}
// ClearForDay drops a pinned pick, handing the day back to the random picker.
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
}
// Schedule lists decided days from today onward, for the admin view.
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
if limit <= 0 || limit > 60 {
limit = 30
}
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
}
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick { func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
kinds := []string{"verse", "prose", "transcript"} kinds := []string{"verse", "prose", "transcript"}
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] }) rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
@@ -0,0 +1 @@
DROP TABLE IF EXISTS message_reactions;
+27
View File
@@ -0,0 +1,27 @@
-- Emoji reactions on chat messages.
--
-- One reaction per person per message: reacting again REPLACES rather than
-- accumulates, which is what the primary key enforces. That matches how the
-- UI reads — a row of distinct emoji with counts — and stops someone stacking
-- twenty reactions on one bubble.
--
-- The emoji is stored as text rather than a code point or shortname: what the
-- sender picked is what everyone should see, and a mapping table would need
-- updating every time the emoji set grows.
CREATE TABLE IF NOT EXISTS message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Denormalized so deleting a conversation takes its reactions with it
-- without relying on the messages cascade alone.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
emoji VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id),
-- A stray empty string would render as an invisible reaction chip.
CONSTRAINT message_reactions_emoji_not_blank CHECK (btrim(emoji) <> '')
);
-- Hydrating a page of messages: one index range scan per batch.
CREATE INDEX IF NOT EXISTS message_reactions_message_idx
ON message_reactions (message_id);
+14
View File
@@ -0,0 +1,14 @@
DELETE FROM message_attachments WHERE kind = 'sticker';
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote'));
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_entity_type_check
CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection'));
DROP TABLE IF EXISTS saved_stickers;
+43
View File
@@ -0,0 +1,43 @@
-- Stickers in chat.
--
-- A sticker IS a sādhana reward — same catalog, same artwork — so there is no
-- separate sticker table. What's new is a second way to come by one: someone
-- sends you theirs and you keep it.
--
-- saved_stickers records only that second route. A reward you earned is
-- already yours through sadhna_reward_grants, and duplicating it here would
-- give two answers to "do I own this?".
CREATE TABLE IF NOT EXISTS saved_stickers (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reward_id UUID NOT NULL REFERENCES sadhna_rewards(id) ON DELETE CASCADE,
-- Who sent it. Kept for provenance, and SET NULL rather than CASCADE so
-- deleting an account doesn't take away stickers other people saved.
saved_from UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, reward_id)
);
-- "What's in my sticker drawer?" — the whole reason this table exists.
CREATE INDEX IF NOT EXISTS saved_stickers_user_idx
ON saved_stickers (user_id, created_at DESC);
-- A sticker message: kind 'sticker', with the artwork denormalized onto the
-- attachment's existing image_url/title columns.
--
-- Denormalized rather than resolved per read, unlike a shared post: the
-- catalog is static art with no viewer-dependent visibility, so there is
-- nothing to re-check and a join on every message would buy nothing.
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote', 'sticker'));
-- entity_id carries the reward key on a sticker attachment, so a recipient can
-- save it without the client having to trust a URL it was handed.
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_entity_type_check
CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection', 'sticker'));
+12
View File
@@ -0,0 +1,12 @@
DELETE FROM sadhna_reward_grants WHERE source = 'gift';
DROP INDEX IF EXISTS sadhna_reward_grants_unopened_idx;
DROP INDEX IF EXISTS sadhna_reward_grants_gift_uniq;
DROP INDEX IF EXISTS sadhna_reward_grants_streak_uniq;
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone SET NOT NULL;
ALTER TABLE sadhna_reward_grants
ADD CONSTRAINT sadhna_reward_grants_user_id_milestone_key UNIQUE (user_id, milestone);
ALTER TABLE sadhna_reward_grants DROP CONSTRAINT IF EXISTS sadhna_reward_grants_source_check;
ALTER TABLE sadhna_reward_grants DROP COLUMN IF EXISTS source;
+41
View File
@@ -0,0 +1,41 @@
-- Occasional gift rewards, granted from the admin panel.
--
-- A gift IS a reward grant — same catalog, same gallery, same vault — so it
-- reuses sadhna_reward_grants rather than introducing a parallel table that
-- every reader would then have to union in. What differs is only where it came
-- from, which `source` records.
--
-- `celebrated_at` already means "the devotee has seen this", which is exactly
-- the claim flag a gift needs: unclaimed gifts render blurred, and dismissing
-- the celebration is what clears them.
ALTER TABLE sadhna_reward_grants
ADD COLUMN IF NOT EXISTS source VARCHAR(8) NOT NULL DEFAULT 'streak';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sadhna_reward_grants_source_check') THEN
ALTER TABLE sadhna_reward_grants
ADD CONSTRAINT sadhna_reward_grants_source_check
CHECK (source IN ('streak', 'gift'));
END IF;
END $$;
-- A gift is not tied to a streak day, so it has no milestone.
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone DROP NOT NULL;
-- UNIQUE (user_id, milestone) was the streak rule: one reward per milestone.
-- It can't police gifts, which have no milestone — so it becomes a partial
-- index over streak rows, and gifts get their own rule: one of each reward
-- per person, so re-gifting is idempotent rather than a duplicate tile.
ALTER TABLE sadhna_reward_grants
DROP CONSTRAINT IF EXISTS sadhna_reward_grants_user_id_milestone_key;
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_streak_uniq
ON sadhna_reward_grants (user_id, milestone) WHERE source = 'streak';
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_gift_uniq
ON sadhna_reward_grants (user_id, reward_id) WHERE source = 'gift';
-- "Which gifts is this devotee still to open?" — the blurred tiles.
CREATE INDEX IF NOT EXISTS sadhna_reward_grants_unopened_idx
ON sadhna_reward_grants (user_id) WHERE celebrated_at IS NULL;
+7
View File
@@ -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;
+60
View File
@@ -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;
+5
View File
@@ -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;
+88
View File
@@ -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;
+14
View File
@@ -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;
@@ -0,0 +1 @@
UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset');
@@ -0,0 +1,6 @@
-- quizzes:reset — delete one participant's attempt so the quiz is pending
-- for them again. Granted to the seeded Admin role (Superadmin has '*';
-- Moderator and custom roles get it only when a superadmin adds it).
UPDATE admin_roles
SET permissions = array_append(permissions, 'quizzes:reset')
WHERE key = 'admin' AND NOT ('quizzes:reset' = ANY(permissions));
@@ -0,0 +1 @@
DROP INDEX IF EXISTS quizzes_one_per_target;
@@ -0,0 +1,6 @@
-- One quiz per piece of content. A chapter, lecture or conversation may carry
-- at most one live quiz; stand-alone quizzes (no path) are unlimited, and a
-- deleted quiz frees its link for a new one.
CREATE UNIQUE INDEX IF NOT EXISTS quizzes_one_per_target
ON quizzes (target_path)
WHERE deleted_at IS NULL AND target_path IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE quizzes DROP COLUMN IF EXISTS target_year;
+5
View File
@@ -0,0 +1,5 @@
-- The year of a lecture or conversation a quiz is attached to, captured when
-- the quiz is saved, so the app can group quizzes by year without looking
-- each transcript up. NULL for chapters, stand-alone quizzes, and undated
-- recordings.
ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS target_year SMALLINT;
@@ -0,0 +1,2 @@
UPDATE admin_roles SET permissions = array_remove(permissions, 'feedback:categories');
DROP TABLE IF EXISTS feedback_categories;
@@ -0,0 +1,41 @@
-- Feedback categories, managed from the admin panel instead of hard-coded in
-- the app and the BFF.
--
-- `key` is what feedback.category stores, and never changes once created —
-- so renaming a category in the admin relabels every past submission too.
-- Archiving hides a category from the /feedback form while existing feedback
-- keeps its label. There is deliberately no foreign key from feedback: the
-- column predates this table, and the admin only hard-deletes categories
-- nothing uses.
CREATE TABLE IF NOT EXISTS feedback_categories (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The four categories the app offered until now, with the exact labels and
-- descriptions its form showed, in the form's order. Production feedback
-- already uses these keys, so they must stay byte-for-byte the same.
INSERT INTO feedback_categories (key, label, description, sort_order) VALUES
('general', 'General feedback', 'Thoughts, impressions, suggestions — anything else.', 0),
('bug', 'Bug report', 'Something is broken or behaving unexpectedly.', 1),
('feature', 'Feature request', 'An idea for something new on the site.', 2),
('other', 'Other', 'Doesn''t fit the above? Use this.', 3)
ON CONFLICT (key) DO NOTHING;
-- Safety net: any other key already present in feedback (from an older
-- client) gets a category too — archived, so it isn't offered on the form,
-- but its submissions still carry a readable label.
INSERT INTO feedback_categories (key, label, sort_order, archived_at)
SELECT DISTINCT f.category, initcap(replace(f.category, '_', ' ')), 100, now()
FROM feedback f
WHERE NOT EXISTS (SELECT 1 FROM feedback_categories c WHERE c.key = f.category)
ON CONFLICT (key) DO NOTHING;
-- The Admin role may manage them; Superadmin already has '*'.
UPDATE admin_roles
SET permissions = array_append(permissions, 'feedback:categories')
WHERE key = 'admin' AND NOT ('feedback:categories' = ANY(permissions));
+121
View File
@@ -0,0 +1,121 @@
// Package richtext renders the light formatting the admin panel's message
// fields allow, for the two places that text leaves the app:
//
// - ToHTML, for email bodies.
// - Plain, for push notifications and toasts, which can't render markup.
//
// The format is a handful of markers typed into a plain textarea:
//
// *bold* _italic_ __underline__ ~strike~ [label](https://…)
//
// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~
// match what chat already uses, so the two read the same.
//
// Safety comes from the order of operations: the input is HTML-escaped FIRST,
// and only then are the markers turned into tags. Nothing an author types can
// therefore produce markup of its own — the only tags in the output are the
// ones this package writes. Links are limited to http and https, so a
// `javascript:` URL is never made clickable.
package richtext
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
)
var (
// A labelled link, or a bare URL. One alternation so a URL is never
// linked twice — once as the label's target and once on its own.
linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`)
underlineRe = regexp.MustCompile(`__([^_\n]+?)__`)
boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`)
strikeRe = regexp.MustCompile(`~([^~\n]+?)~`)
// Italic needs a non-word character (or an edge) on each side, so the
// underscores inside snake_case or a handle aren't read as markers. RE2
// has no lookbehind, so the neighbours are captured and put back.
italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`)
// Punctuation that ends a sentence rather than belonging to the URL.
trailingPunct = ".,;:!?)"
)
// placeholder marks where a finished link sits while markers are applied, so
// the underscores and tildes common in URLs aren't read as formatting.
func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) }
// splitTrailing separates sentence punctuation from the end of a bare URL.
func splitTrailing(u string) (url, tail string) {
end := len(u)
for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) {
end--
}
return u[:end], u[end:]
}
// ToHTML renders the markers as HTML for an email body.
func ToHTML(s string) template.HTML {
escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n"))
var links []string
withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string {
parts := linkRe.FindStringSubmatch(m)
var label, href, tail string
if parts[1] != "" {
label, href = parts[1], parts[2]
} else {
href, tail = splitTrailing(parts[3])
label = href
}
// href is already escaped (it came from the escaped input), so it is
// safe inside the attribute as-is.
links = append(links, fmt.Sprintf(
`<a href="%s" style="color:#9a6b2f;font-weight:600">%s</a>`, href, label))
return placeholder(len(links)-1) + tail
})
out := underlineRe.ReplaceAllString(withHolders, `<u>$1</u>`)
out = boldRe.ReplaceAllString(out, `<strong>$1</strong>`)
out = strikeRe.ReplaceAllString(out, `<s>$1</s>`)
out = italicRe.ReplaceAllString(out, `$1<em>$2</em>$3`)
for i, l := range links {
out = strings.Replace(out, placeholder(i), l, 1)
}
out = strings.ReplaceAll(out, "\n", "<br>")
// Every tag here was written by this function, and all author text was
// escaped before any of them were added.
return template.HTML(out) //nolint:gosec // see above
}
// Plain strips the markers for surfaces that can't render them — a push
// notification or a toast. A labelled link keeps only its label; a bare URL
// stays as written, since it is still useful to see.
func Plain(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
var links []string
withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string {
parts := linkRe.FindStringSubmatch(m)
if parts[1] != "" {
links = append(links, parts[1])
} else {
links = append(links, parts[3])
}
return placeholder(len(links) - 1)
})
out := underlineRe.ReplaceAllString(withHolders, `$1`)
out = boldRe.ReplaceAllString(out, `$1`)
out = strikeRe.ReplaceAllString(out, `$1`)
out = italicRe.ReplaceAllString(out, `$1$2$3`)
for i, l := range links {
out = strings.Replace(out, placeholder(i), l, 1)
}
return out
}
+85
View File
@@ -0,0 +1,85 @@
package richtext
import (
"strings"
"testing"
)
func TestToHTML_Markers(t *testing.T) {
cases := []struct{ in, want string }{
{"*bold*", "<strong>bold</strong>"},
{"_italic_", "<em>italic</em>"},
{"__under__", "<u>under</u>"},
{"~gone~", "<s>gone</s>"},
{"a *b* and _c_", "a <strong>b</strong> and <em>c</em>"},
{"line one\nline two", "line one<br>line two"},
// Underscores inside words are not italics.
{"snake_case_name", "snake_case_name"},
{"@some_handle here", "@some_handle here"},
}
for _, c := range cases {
if got := string(ToHTML(c.in)); got != c.want {
t.Errorf("ToHTML(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestToHTML_Links(t *testing.T) {
got := string(ToHTML("see [the gītā](https://prabhupadasays.org/books/bg)"))
if !strings.Contains(got, `href="https://prabhupadasays.org/books/bg"`) ||
!strings.Contains(got, ">the gītā</a>") {
t.Errorf("labelled link: %q", got)
}
// A bare URL becomes a link, but sentence punctuation stays outside it.
got = string(ToHTML("Visit https://prabhupadasays.org."))
if !strings.Contains(got, `href="https://prabhupadasays.org"`) ||
!strings.HasSuffix(got, "</a>.") {
t.Errorf("bare link with trailing period: %q", got)
}
// Underscores and tildes inside a URL are not formatting.
got = string(ToHTML("https://example.com/a_b_c~d"))
if strings.Contains(got, "<em>") || strings.Contains(got, "<s>") {
t.Errorf("markers applied inside a URL: %q", got)
}
}
// The security property: nothing typed can become markup of its own.
func TestToHTML_EscapesAuthorInput(t *testing.T) {
cases := []string{
`<script>alert(1)</script>`,
`<img src=x onerror=alert(1)>`,
`*<b>bold</b>*`,
`[click](javascript:alert(1))`,
`[x](https://ok.example/" onmouseover="alert(1))`,
}
for _, in := range cases {
got := string(ToHTML(in))
if strings.Contains(got, "<script") || strings.Contains(got, "<img") ||
strings.Contains(got, "<b>") || strings.Contains(got, "javascript:alert") && strings.Contains(got, "href=\"javascript") {
t.Errorf("ToHTML(%q) produced author markup: %q", in, got)
}
if strings.Contains(got, `" onmouseover=`) {
t.Errorf("ToHTML(%q) let a quote break out of an attribute: %q", in, got)
}
}
// A javascript: URL is never made a link at all.
if got := string(ToHTML(`[click](javascript:alert(1))`)); strings.Contains(got, "<a ") {
t.Errorf("javascript: URL was linked: %q", got)
}
}
func TestPlain(t *testing.T) {
cases := []struct{ in, want string }{
{"*New:* _chat_ is __here__ ~soon~", "New: chat is here soon"},
{"read [the gītā](https://x.example)", "read the gītā"},
{"see https://prabhupadasays.org", "see https://prabhupadasays.org"},
{"snake_case stays", "snake_case stays"},
}
for _, c := range cases {
if got := Plain(c.in); got != c.want {
t.Errorf("Plain(%q) = %q, want %q", c.in, got, c.want)
}
}
}