19 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
Krispire Admin 9c458ba19f fix: no attachment broadcast fix 2026-08-29 18:56:33 +05:30
Krispire Admin f65f552c87 feat: broadcast 2026-08-29 18:34:07 +05:30
Krispire Admin 44120efdec fix: chat UI 2026-08-29 17:30:14 +05:30
Krispire Admin 7a730cd42e fix: chat accepts posts 2026-08-29 16:26:38 +05:30
Krispire Admin 18bac9a22d fix: chat accepts quotes now 2026-08-29 11:58:20 +05:30
81 changed files with 8882 additions and 186 deletions
+2
View File
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
# Frontend (CORS + email link base)
FRONTEND_URL=http://localhost:3000
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
ADMIN_URL=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
+26 -3
View File
@@ -8,6 +8,8 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
@@ -15,9 +17,15 @@ import (
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
// the router, so every method here can assume an admin caller.
type AdminHandler struct{ svc *service.AdminService }
type AdminHandler struct {
svc *service.AdminService
// staff guards actions that would otherwise reach other staff members.
staff *repository.StaffRepo
}
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
return &AdminHandler{svc: svc, staff: staff}
}
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
func pageParams(r *http.Request) (q string, limit, offset int) {
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
}
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
target := chi.URLParam(r, "id")
// A banned account loses panel access, so banning a staff member is a way
// to remove them — which only superadmins may do. Without this, an Admin
// holding users:ban could ban a Superadmin out of the panel.
if _, err := h.staff.Access(r.Context(), target); err == nil {
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
response.Error(w, http.StatusForbidden,
"staff accounts are managed from the Staff page by a superadmin")
return
}
if target == middleware.UserID(r.Context()) {
response.Error(w, http.StatusConflict, "you can't ban yourself")
return
}
}
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update account")
return
+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"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"
)
// BroadcastHandler serves both sides of announcements: the admin routes that
// compose and publish them, and the two app routes that fetch and dismiss.
type BroadcastHandler struct {
svc *service.BroadcastService
}
func NewBroadcastHandler(svc *service.BroadcastService) *BroadcastHandler {
return &BroadcastHandler{svc: svc}
}
func writeBroadcastError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrBroadcastGone):
response.Error(w, http.StatusNotFound, "broadcast not found")
case errors.Is(err, service.ErrBroadcastTitle),
errors.Is(err, service.ErrBroadcastBody),
errors.Is(err, service.ErrBroadcastCTA),
errors.Is(err, service.ErrBroadcastBanner):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "broadcast request failed")
}
}
// ── App routes ────────────────────────────────────────────────────────────
// Pending — GET /broadcasts/pending. Returns null when nothing is waiting, so
// the client can call it on load and after a nudge without special-casing.
func (h *BroadcastHandler) Pending(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Pending(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// Seen — POST /broadcasts/{id}/seen. The "at most once" half; idempotent.
func (h *BroadcastHandler) Seen(w http.ResponseWriter, r *http.Request) {
if err := h.svc.MarkSeen(r.Context(), chi.URLParam(r, "id"),
middleware.UserID(r.Context())); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// ── Admin routes ──────────────────────────────────────────────────────────
type createBroadcastReq struct {
Title string `json:"title"`
Body string `json:"body"`
BannerKey *string `json:"banner_key"`
CTALabel *string `json:"cta_label"`
CTAURL *string `json:"cta_url"`
// ExpiresAt stops the announcement being offered. Without one, someone
// signing up months later would still be shown it.
ExpiresAt *time.Time `json:"expires_at"`
// Publish immediately rather than saving a draft.
Publish bool `json:"publish"`
}
// Create — POST /admin/broadcasts
func (h *BroadcastHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createBroadcastReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
b, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), service.CreateParams{
Title: req.Title, Body: req.Body, BannerKey: req.BannerKey,
CTALabel: req.CTALabel, CTAURL: req.CTAURL, ExpiresAt: req.ExpiresAt,
})
if err != nil {
writeBroadcastError(w, err)
return
}
if req.Publish {
published, err := h.svc.Publish(r.Context(), b.ID)
if err != nil {
writeBroadcastError(w, err)
return
}
b = published
}
response.JSON(w, http.StatusCreated, b)
}
// Publish — POST /admin/broadcasts/{id}/publish
func (h *BroadcastHandler) Publish(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Publish(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// List — GET /admin/broadcasts
func (h *BroadcastHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.svc.List(r.Context(), limit)
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Delete — DELETE /admin/broadcasts/{id}
func (h *BroadcastHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// PresignBanner — POST /admin/broadcasts/banner-presign
func (h *BroadcastHandler) PresignBanner(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, key, err := h.svc.PresignBanner(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, "key": key})
}
+156 -1
View File
@@ -36,6 +36,14 @@ func writeChatError(w http.ResponseWriter, err error) {
response.Error(w, http.StatusNotFound, "conversation not found")
case errors.Is(err, service.ErrAvatarUnset):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrChatBadEntity):
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):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadAvatarKey):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrMessageNotHere):
@@ -92,6 +100,17 @@ type openDMReq struct {
Handle string `json:"handle"`
}
// Unread — GET /chat/unread. Drives the nav badge, so it stays a single
// count rather than anything the caller has to aggregate.
func (h *ChatHandler) Unread(w http.ResponseWriter, r *http.Request) {
n, err := h.svc.UnreadCount(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
// thread when there is one, so the client can call it every time it opens a
// profile without checking first.
@@ -186,7 +205,15 @@ type sendReq struct {
// when they dismissed it. Only the URL — never the card's contents.
PreviewURL *string `json:"preview_url"`
Attachments []mediaReq `json:"attachments"`
ReplyToID *string `json:"reply_to_id"`
// quote is a passage shared from the library. `path` must be app-relative;
// the service refuses anything that could navigate off-site.
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
// reference; the BFF resolves the contents and re-checks visibility.
Entity *entityReq `json:"entity"`
ReplyToID *string `json:"reply_to_id"`
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
// its own "can the caller see the source?" check in a later phase, and
// accepting an unvalidated message reference in the meantime would let a
@@ -210,11 +237,24 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
FileName: a.FileName,
})
}
var quote *service.QuoteAttachment
if req.Quote != nil {
quote = &service.QuoteAttachment{
Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text,
}
}
var entity *service.EntityAttachment
if req.Entity != nil {
entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID}
}
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
Body: req.Body,
ClientMsgID: req.ClientMsgID,
ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL,
Quote: quote,
StickerKey: req.StickerKey,
Entity: entity,
Attachments: media,
})
if err != nil {
@@ -554,6 +594,17 @@ func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
response.NoContent(w)
}
type entityReq struct {
Type string `json:"type"`
ID string `json:"id"`
}
type quoteReq struct {
Path string `json:"path"`
Title string `json:"title"`
Text string `json:"text"`
}
type receiptReq struct {
Seq int64 `json:"seq"`
}
@@ -604,6 +655,110 @@ func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
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
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"))
+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 (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"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/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
// 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) {
uid := middleware.UserID(r.Context())
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
+13 -7
View File
@@ -82,13 +82,18 @@ const searchStopwordsSet = "common"
// searchResultItem represents a single search result in the API response.
type searchResultItem struct {
Collection string `json:"collection"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
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"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
}
// searchResponse is the shape of the data field in the API response.
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
item := searchResultItem{
Collection: collection,
DocID: getStringField(doc, "id"),
URL: getStringField(doc, "url"),
score: score,
}
+33 -6
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -41,11 +42,29 @@ type SoketiWebhookHandler struct {
appKey string
appSecret string
feed *repository.FeedRepo
logger *zap.Logger
// chat records presence so a message doesn't push to someone who is
// already looking at the app. Optional.
chat ChatPresence
logger *zap.Logger
}
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler {
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger}
// ChatPresence is the slice of ChatService this handler needs — narrow so the
// webhook doesn't depend on the whole chat surface.
type ChatPresence interface {
MarkPresence(ctx context.Context, userID string, online bool)
}
func (h *SoketiWebhookHandler) presence(ctx context.Context, userID string, online bool) {
if h.chat == nil {
return
}
h.chat.MarkPresence(ctx, userID, online)
}
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, chat ChatPresence, logger *zap.Logger) *SoketiWebhookHandler {
return &SoketiWebhookHandler{
appKey: appKey, appSecret: appSecret, feed: feed, chat: chat, logger: logger,
}
}
type soketiWebhookBody struct {
@@ -91,12 +110,20 @@ func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cleared := 0
for _, e := range payload.Events {
// A per-user notifications channel emptying = that user is offline.
if e.Name != "channel_vacated" {
uid, ok := userIDFromNotifChannel(e.Channel)
if !ok {
continue
}
if uid, ok := userIDFromNotifChannel(e.Channel); ok {
switch e.Name {
case "channel_occupied":
// This user has a live connection, so chat push is suppressed
// for them — they will see the message in the app.
h.presence(ctx, uid, true)
case "channel_vacated":
// Their last tab closed: they are offline. Evict the feed
// seen-set and let chat push resume.
h.feed.Reset(ctx, uid)
h.presence(ctx, uid, false)
cleared++
}
}
+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
import (
"encoding/json"
"errors"
"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/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
// 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) {
payload, err := h.svc.Today(r.Context())
if err != nil {
+53 -8
View File
@@ -1,17 +1,38 @@
package middleware
import (
"context"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
// RequireAuth (it reads the authenticated user id from context, then looks up
// the row to check the role). Non-admins get 403; this keeps the whole
// /api/v1/admin/* surface invisible to ordinary accounts.
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
type staffCtxKey struct{}
// StaffContext is the caller's resolved admin access, set by RequireStaff.
type StaffContext struct {
RoleKey string
RoleName string
Perms rbac.Set
}
// Staff returns the caller's staff context, or nil outside RequireStaff.
func Staff(ctx context.Context) *StaffContext {
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
return s
}
// RequireStaff admits accounts that hold an admin role and aren't blocked from
// the panel. It MUST run after RequireAuth.
//
// It resolves the role's permissions once per request and puts them in
// context, so the RequirePermission checks on each route are map lookups
// rather than further queries. Being staff gets you through the door only;
// every route then names the permission it needs.
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := UserID(r.Context())
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
u, err := users.FindByID(r.Context(), uid)
access, err := staff.Access(r.Context(), uid)
if errors.Is(err, repository.ErrNotStaff) {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "auth check failed")
return
}
if u.Role != "admin" {
response.Error(w, http.StatusForbidden, "admin access required")
if access.Blocked {
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
return
}
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
RoleKey: access.RoleKey,
RoleName: access.RoleName,
Perms: rbac.NewSet(access.Permissions),
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequirePermission gates a route to staff holding perm. Must run inside
// RequireStaff; outside it there is no staff context and everything is refused.
func RequirePermission(perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := Staff(r.Context())
if s == nil || !s.Perms.Has(perm) {
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
return
}
next.ServeHTTP(w, r)
+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) }
+20 -17
View File
@@ -17,8 +17,10 @@ type AdminUser struct {
Plan string `json:"plan"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
BannedAt *time.Time `json:"banned_at"`
PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"`
// AdminRole is the staff role key, nil for ordinary accounts.
AdminRole *string `json:"admin_role,omitempty"`
PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"`
// Connected auth methods. HasPassword means email+password login is set up;
// Providers lists linked OAuth providers (e.g. "google", "facebook").
@@ -56,18 +58,18 @@ type AdminDisciple struct {
// AdminPost is a row in the admin posts table.
type AdminPost struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceTitle string `json:"source_title"`
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceTitle string `json:"source_title"`
SourceURL string `json:"source_url"`
IsHidden bool `json:"is_hidden"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
AuthorName string `json:"author_name"`
AuthorHandle string `json:"author_handle"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
AuthorName string `json:"author_name"`
AuthorHandle string `json:"author_handle"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
}
// AdminCollection is a row in the admin collections table.
@@ -85,18 +87,19 @@ type AdminCollection struct {
// AdminFeedback is a row in the admin feedback table (includes the reply).
type AdminFeedback struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
ID string `json:"id"`
Category string `json:"category"`
CategoryLabel string `json:"category_label"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
ResolvedAt *time.Time `json:"resolved_at"`
RepliedAt *time.Time `json:"replied_at"`
ReplyMessage *string `json:"reply_message"`
AuthorHandle *string `json:"author_handle"`
AuthorName *string `json:"author_name"`
AuthorEmail *string `json:"author_email"`
ReplyMessage *string `json:"reply_message"`
AuthorHandle *string `json:"author_handle"`
AuthorName *string `json:"author_name"`
AuthorEmail *string `json:"author_email"`
}
// AdminReport is a unified projection over the three report tables
+40
View File
@@ -0,0 +1,40 @@
package model
import (
"encoding/json"
"time"
)
// Broadcast is an announcement shown to every user exactly once.
//
// Deliberately not a notification: it creates no tray rows and no per-user
// records until someone actually dismisses it. The modal is the delivery
// surface; push and the realtime nudge only tell clients to go look.
type Broadcast struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
// Entities are offset/length formatting spans, the same shape chat uses,
// so no HTML is ever stored or rendered.
Entities json.RawMessage `json:"entities"`
// BannerURL is resolved from the stored R2 key for the client.
BannerURL *string `json:"banner_url,omitempty"`
CTALabel *string `json:"cta_label,omitempty"`
CTAURL *string `json:"cta_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// SeenCount is filled for the admin list only — how many people have
// dismissed it so far.
SeenCount int64 `json:"seen_count,omitempty"`
}
// Live reports whether the broadcast is currently deliverable.
func (b *Broadcast) Live(now time.Time) bool {
if b.PublishedAt == nil || b.PublishedAt.After(now) {
return false
}
return b.ExpiresAt == nil || b.ExpiresAt.After(now)
}
+57
View File
@@ -167,6 +167,20 @@ const (
AttachmentFile = "file"
AttachmentLink = "link"
AttachmentEntity = "entity"
// AttachmentQuote is a passage shared from the library: url is an
// app-relative deep link back to the phrase, title the source reference,
// description the passage itself.
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.
const (
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.
@@ -197,6 +211,16 @@ type Attachment struct {
SiteName *string `json:"site_name,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
// Shared entity (a post, for now). EntityType/EntityID are the durable
// reference; Entity is resolved per viewer at read time, so the same row
// renders differently — or not at all — depending on who is looking.
EntityType *string `json:"entity_type,omitempty"`
EntityID *string `json:"entity_id,omitempty"`
Entity json.RawMessage `json:"entity,omitempty"`
// EntityUnavailable is set when the entity was deleted, hidden, or the
// viewer has since lost access. The client renders a tombstone.
EntityUnavailable bool `json:"entity_unavailable,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -233,6 +257,39 @@ type Message struct {
// Attachments is always present (possibly empty) so clients need no nil
// check before iterating.
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
+16 -1
View File
@@ -18,7 +18,10 @@ type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
Category string `json:"category"`
Message string `json:"message"`
// 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"`
ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"`
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
@@ -50,3 +53,15 @@ type Feedback struct {
AuthorName *string `json:"author_name,omitempty"`
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
// multiple of 7 days). The full reward is embedded for direct rendering.
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"`
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
+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,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
u.disciple_id,
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
COUNT(*) OVER() AS total
FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers,
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
&u.DiscipleID, &total); err != nil {
&u.DiscipleID, &u.AdminRole, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err)
}
// Resolve plan default + overrides so the panel shows what's in force.
@@ -247,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
status = "all"
}
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,
u.handle, u.name, u.email,
COUNT(*) OVER() AS total
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE ($1 = 'all'
OR ($1 = 'open' AND f.resolved_at IS 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
for rows.Next() {
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.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
+151
View File
@@ -0,0 +1,151 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBroadcastGone = errors.New("broadcast not found")
type BroadcastRepo struct{ pg *Postgres }
func NewBroadcastRepo(pg *Postgres) *BroadcastRepo { return &BroadcastRepo{pg: pg} }
const broadcastColumns = `id, title, body, entities, banner_key, cta_label,
cta_url, published_at, expires_at, created_at`
// scanBroadcast decodes a row produced by broadcastColumns. bannerKey stays
// out of the model — the service turns it into a URL.
func scanBroadcast(row pgx.Row, b *model.Broadcast, bannerKey **string) error {
return row.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, bannerKey,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt)
}
type CreateBroadcastParams struct {
Title string
Body string
Entities []byte
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
CreatedBy string
}
func (r *BroadcastRepo) Create(ctx context.Context, p CreateBroadcastParams) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
INSERT INTO broadcasts (title, body, entities, banner_key, cta_label,
cta_url, expires_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+broadcastColumns,
p.Title, p.Body, p.Entities, p.BannerKey, p.CTALabel, p.CTAURL,
p.ExpiresAt, p.CreatedBy), &b, &key)
if err != nil {
return nil, nil, fmt.Errorf("create broadcast: %w", err)
}
return &b, key, nil
}
// Publish stamps published_at, making the broadcast deliverable. Idempotent:
// re-publishing keeps the original timestamp so the live window doesn't shift.
func (r *BroadcastRepo) Publish(ctx context.Context, id string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
UPDATE broadcasts
SET published_at = COALESCE(published_at, now()), updated_at = now()
WHERE id = $1
RETURNING `+broadcastColumns, id), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrBroadcastGone
}
if err != nil {
return nil, nil, fmt.Errorf("publish broadcast: %w", err)
}
return &b, key, nil
}
func (r *BroadcastRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM broadcasts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete broadcast: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBroadcastGone
}
return nil
}
// Pending returns the newest live broadcast this user has not dismissed.
func (r *BroadcastRepo) Pending(ctx context.Context, userID string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
SELECT `+broadcastColumns+`
FROM broadcasts b
WHERE b.published_at IS NOT NULL
AND b.published_at <= now()
AND (b.expires_at IS NULL OR b.expires_at > now())
AND NOT EXISTS (
SELECT 1 FROM broadcast_views v
WHERE v.broadcast_id = b.id AND v.user_id = $1)
-- Newest first. Announcements are time-sensitive, so a stale one
-- must not gate a fresh one; dismissing this reveals the next, so
-- nothing is skipped either way — only the order differs.
ORDER BY b.published_at DESC
LIMIT 1`, userID), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil // nothing waiting is not an error
}
if err != nil {
return nil, nil, fmt.Errorf("pending broadcast: %w", err)
}
return &b, key, nil
}
// MarkSeen records a dismissal. Idempotent — two tabs can race.
func (r *BroadcastRepo) MarkSeen(ctx context.Context, id, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO broadcast_views (broadcast_id, user_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`, id, userID)
if err != nil {
return fmt.Errorf("mark broadcast seen: %w", err)
}
return nil
}
// List returns broadcasts newest first, with dismissal counts, for the admin.
func (r *BroadcastRepo) List(ctx context.Context, limit int) ([]model.Broadcast, []*string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT `+broadcastColumns+`,
(SELECT count(*) FROM broadcast_views v WHERE v.broadcast_id = b.id)
FROM broadcasts b
ORDER BY COALESCE(b.published_at, b.created_at) DESC
LIMIT $1`, limit)
if err != nil {
return nil, nil, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := []model.Broadcast{}
keys := []*string{}
for rows.Next() {
var b model.Broadcast
var key *string
if err := rows.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, &key,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt,
&b.SeenCount); err != nil {
return nil, nil, fmt.Errorf("scan broadcast: %w", err)
}
out = append(out, b)
keys = append(keys, key)
}
return out, keys, rows.Err()
}
+353 -22
View File
@@ -23,6 +23,9 @@ var (
ErrAttachmentGone = errors.New("attachment not found")
ErrMessageNotHere = errors.New("message is not in this conversation")
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 }
@@ -336,27 +339,58 @@ func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, lim
// Messages returns one page of history, newest first. beforeSeq == 0 starts at
// the newest message; otherwise it is an exclusive upper bound, which makes
// paging stable even as new messages arrive at the top.
// messageSelectFrom is the canonical read shape for a message, including the
// 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,
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, '')
FROM messages m
LEFT JOIN LATERAL (
SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name
FROM messages r
LEFT JOIN users ru ON ru.id = r.sender_id
-- The conversation guard is repeated here on purpose: even a row
-- written before the write-side check existed cannot leak another
-- thread's text through a quote.
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
) 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) {
rows, err := r.pg.Pool.Query(ctx, `
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,
rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '')
FROM messages m
LEFT JOIN LATERAL (
SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name
FROM messages r
LEFT JOIN users ru ON ru.id = r.sender_id
-- The conversation guard is repeated here on purpose: even a row
-- written before the write-side check existed cannot leak another
-- thread's text through a quote.
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
) rep ON TRUE
return r.scanMessages(ctx, messageSelectFrom+`
WHERE m.conversation_id = $1
AND ($2 = 0 OR m.seq < $2)
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = m.id AND h.user_id = $3)
ORDER BY m.seq DESC
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 {
return nil, fmt.Errorf("query messages: %w", err)
}
@@ -547,6 +581,12 @@ type AddAttachmentParams struct {
Description *string
SiteName *string
ImageURL *string
// Shared entity: the durable reference plus a snapshot used only as a
// fallback. Live data is re-resolved per viewer at read time.
EntityType *string
EntityID *string
Snapshot []byte
}
// AddAttachment persists one attachment. conversation_id is denormalized here
@@ -557,17 +597,20 @@ func (r *ChatRepo) AddAttachment(ctx context.Context, p AddAttachmentParams) (*m
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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
RETURNING id, message_id, ord, kind, mime, size_bytes, width, height,
duration_ms, blurhash, file_name, url, title, description,
site_name, image_url, created_at`,
site_name, image_url, entity_type, entity_id, snapshot, created_at`,
p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes,
p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName,
p.URL, p.Title, p.Description, p.SiteName, p.ImageURL,
p.EntityType, p.EntityID, p.Snapshot,
).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width,
&a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title,
&a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt)
&a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt)
if err != nil {
return nil, fmt.Errorf("add attachment: %w", err)
}
@@ -583,7 +626,7 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, message_id, ord, kind, mime, size_bytes, width, height,
duration_ms, blurhash, file_name, url, title, description,
site_name, image_url, created_at
site_name, image_url, entity_type, entity_id, snapshot, created_at
FROM message_attachments
WHERE message_id = ANY($1)
ORDER BY message_id, ord`, messageIDs)
@@ -595,7 +638,8 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map
var a model.Attachment
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
&a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil {
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
out[a.MessageID] = append(out[a.MessageID], a)
@@ -640,7 +684,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k
rows, err := r.pg.Pool.Query(ctx, `
SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width,
a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title,
a.description, a.site_name, a.image_url, a.created_at
a.description, a.site_name, a.image_url,
a.entity_type, a.entity_id, a.snapshot, a.created_at
FROM message_attachments a
JOIN messages m ON m.id = a.message_id
WHERE a.conversation_id = $1
@@ -661,7 +706,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k
var a model.Attachment
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
&a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil {
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("scan gallery row: %w", err)
}
out = append(out, a)
@@ -1145,6 +1191,291 @@ func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, con
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
// message should reach them out-of-band.
type PushCandidate struct {
UserID string
MutedUntil *time.Time
LastReadSeq int64
}
// PushCandidates lists everyone still in the thread except the sender.
func (r *ChatRepo) PushCandidates(ctx context.Context, conversationID, senderID string) ([]PushCandidate, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT user_id, muted_until, last_read_seq
FROM conversation_participants
WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL`,
conversationID, senderID)
if err != nil {
return nil, fmt.Errorf("push candidates: %w", err)
}
defer rows.Close()
out := []PushCandidate{}
for rows.Next() {
var c PushCandidate
if err := rows.Scan(&c.UserID, &c.MutedUntil, &c.LastReadSeq); err != nil {
return nil, fmt.Errorf("scan push candidate: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// UnreadConversations counts the caller's threads holding unread messages.
//
// Counts THREADS, not messages: the badge answers "is anything waiting for
// me?", and one chatty group shouldn't dominate it.
//
// Muted threads are excluded. Unread still accrues on them and the inbox still
// shows it — mute silences the alert, and a badge is an alert.
func (r *ChatRepo) UnreadConversations(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*)
FROM conversation_participants p
JOIN conversations c ON c.id = p.conversation_id
WHERE p.user_id = $1
AND p.left_at IS NULL
AND c.last_seq > p.last_read_seq
AND (p.muted_until IS NULL OR p.muted_until <= now())`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread conversations: %w", err)
}
return n, nil
}
// SetMute sets or clears the caller's mute on a thread. A nil until unmutes.
//
// Mute is per participant, not per conversation: muting a group silences it for
@@ -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) {
f := &model.Feedback{}
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,
u.handle, u.name, u.email
FROM feedback f
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,
).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.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, category, message, contact_email, created_at,
acknowledged_at, resolved_at, replied_at, reply_message
FROM feedback
WHERE user_id = $1
ORDER BY created_at DESC
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.replied_at, f.reply_message
FROM feedback f
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.user_id = $1
ORDER BY f.created_at DESC
LIMIT $2`, userID, limit,
)
if err != nil {
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
out := []model.Feedback{}
for rows.Next() {
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 {
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
}
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.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
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
LIMIT $1`, limit,
)
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
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.AcknowledgedAt, &f.ResolvedAt,
&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
FROM users u
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)
ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`,
+40
View File
@@ -160,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
return p, nil
}
// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted
// ids are simply absent from the map.
//
// Exists so a page of chat messages sharing N posts costs one query instead of
// N — the same reason ImagesFor is batched.
func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) {
out := make(map[string]*model.Post, len(ids))
if len(ids) == 0 {
return out, nil
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL`
rows, err := r.pg.Pool.Query(ctx, q, ids)
if err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
defer rows.Close()
found := make([]string, 0, len(ids))
for rows.Next() {
p := &model.Post{}
if err := scanPost(rows, p); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
out[p.ID] = p
found = append(found, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
imgs, err := r.ImagesFor(ctx, found)
if err != nil {
return nil, err
}
for id, p := range out {
p.Images = imgs[id]
}
return out, nil
}
// ListByHandle returns the profile feed for a user. Unknown handle →
// empty page (matches pre-existing behaviour).
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
+24
View File
@@ -54,6 +54,30 @@ func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushS
// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for
// a user, deleting older ones — a soft per-user device cap.
// SubscribedUserIDs lists everyone with at least one live push subscription.
//
// Used by broadcasts, which fan out to the whole user base: iterating users
// and asking each for subscriptions would query for the (large) majority who
// have never enabled push.
func (r *PushRepo) SubscribedUserIDs(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT DISTINCT user_id FROM push_subscriptions`)
if err != nil {
return nil, fmt.Errorf("subscribed users: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan subscribed user: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error {
if keep < 1 {
keep = 1
+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"
_ "embed"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"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
// 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
@@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er
// reward embedded for direct rendering.
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
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
FROM sadhna_reward_grants g
JOIN sadhna_rewards w ON w.id = g.reward_id
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,
)
if err != nil {
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
for rows.Next() {
var g model.SadhnaRewardGrant
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.ImageURL, &g.Reward.Active, &g.Reward.Sort,
); err != nil {
@@ -180,8 +187,106 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
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
// 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) {
var g model.SadhnaRewardGrant
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
FROM sadhna_reward_grants g
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
LIMIT 1`,
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.ImageURL, &g.Reward.Active, &g.Reward.Sort,
)
g.Source = "streak"
if err != nil {
return nil, nil // no rows / nothing pending
}
+29
View File
@@ -242,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
// BlockedWith returns the subset of others that viewerID is blocked with in
// EITHER direction — the set whose content must be hidden from them.
//
// The batched counterpart to BlockState, for resolving many authors at once.
func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) {
out := make(map[string]bool, len(others))
if viewerID == "" || len(others) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END
FROM user_blocks
WHERE (blocker_id = $1 AND blocked_id = ANY($2))
OR (blocked_id = $1 AND blocker_id = ANY($2))`,
viewerID, others)
if err != nil {
return nil, fmt.Errorf("blocked with: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan blocked with: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT
+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
// would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{}
// Non-nil so an empty table serialises as [] rather than null.
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct {
+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.
var total int
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 {
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.
sql := `SELECT ` + userColumns + `
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
CASE
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
WHERE u.created_at >= $2
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 (
SELECT 1 FROM follows f
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
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
// 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 {
pg *Postgres
mongo *MongoDB
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
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),
// 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
@@ -135,6 +200,107 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
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
// _id hexes. Returns nil when nothing matches.
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
+178 -41
View File
@@ -12,6 +12,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/internal/worker"
@@ -75,8 +76,13 @@ type Server struct {
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
feedbackCatH *handler.FeedbackCategoryHandler
statsH *handler.StatsHandler
adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler
staffRepo *repository.StaffRepo
staffH *handler.StaffHandler
quizH *handler.QuizHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer
@@ -194,9 +200,6 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// across pages (nil-safe — falls back to offset paging without Redis).
feedRepo := repository.NewFeedRepo(s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
// Soketi signs webhooks with the app secret; on channel_vacated for a
// per-user notifications channel we evict that user's feed seen-set.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -226,6 +229,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
// target title and kind.
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
// OAuth — wired only when at least one provider is configured. The
// registry skips providers with missing creds, so partial configs (e.g.
// Google only, no Facebook) work out of the box.
@@ -259,11 +266,27 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
// per-user notifications channel tells us whether they are connected: we
// evict the feed seen-set when they leave, and chat uses the same signal
// to skip pushing to someone who is already in the app. Constructed after
// chatSvc because it depends on it.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(
cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, chatSvc, logger)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
@@ -271,11 +294,6 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
handler.ConversationRule(chatSvc.IsParticipant),
)
pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc)
@@ -319,15 +337,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
if err := rewardRepo.Seed(context.Background()); err != nil {
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)
// Feedback + stats — share Postgres with the rest of /api/v1 and
// the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(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.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
// Admin panel — read-mostly listing + notify/reply/resolve actions.
@@ -335,7 +356,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Gated by RequireAuth + RequireAdmin in the router.
adminRepo := repository.NewAdminRepo(s.pg)
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc)
// Admin RBAC: who is staff, with which permissions.
s.staffRepo = repository.NewStaffRepo(s.pg)
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
// Broadcasts — admin announcements shown once per user. No notification
// rows: the modal is the surface, and push plus the realtime nudge only
// tell clients to go and check for one.
broadcastSvc := service.NewBroadcastService(
repository.NewBroadcastRepo(s.pg), pushSender, pushRepo,
notifPrefsRepo, rtClient, r2, logger)
s.broadcastH = handler.NewBroadcastHandler(broadcastSvc)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -462,8 +494,14 @@ func (s *Server) setupRouter() {
// CORS — the main frontend plus, when configured, the admin panel (a
// separate origin). Empty origins are filtered so the list never widens
// unintentionally.
adminURL := s.cfg.AdminURL
if adminURL == "" && s.cfg.Env == "development" {
// The admin app's dev server (`next dev -p 3001`), so local admin
// work needs no extra config. Production must set ADMIN_URL.
adminURL = "http://localhost:3001"
}
allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
if o != "" {
allowedOrigins = append(allowedOrigins, o)
}
@@ -691,6 +729,26 @@ func (s *Server) setupRouter() {
// Vāṇī of the Day — public daily content pick for the home page.
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 ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
@@ -905,10 +963,21 @@ func (s *Server) setupRouter() {
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.broadcastH != nil {
// Announcements: fetch the one waiting, and dismiss it.
r.Route("/broadcasts", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/pending", s.broadcastH.Pending)
r.Post("/{id}/seen", s.broadcastH.Seen)
})
}
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Get("/unread", s.chatH.Unread)
r.Get("/stickers", s.chatH.Stickers)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
@@ -925,6 +994,7 @@ func (s *Server) setupRouter() {
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/typing", s.chatH.Typing)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
@@ -935,6 +1005,11 @@ func (s *Server) setupRouter() {
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
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.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
@@ -1041,6 +1116,8 @@ func (s *Server) setupRouter() {
r.HandleFunc("/feedback/mine", s.authUnavailable)
return
}
// The form's categories — public, like the form itself.
r.Get("/feedback/categories", s.feedbackCatH.Public)
// Submit — OptionalAuth (anonymous callers allowed).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
@@ -1099,6 +1176,7 @@ func (s *Server) setupRouter() {
r.Post("/chant", s.sadhnaH.SaveChant)
r.Get("/rewards", s.sadhnaH.Rewards)
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
if s.reminderH != nil {
r.Get("/reminders", s.reminderH.GetPrefs)
r.Put("/reminders", s.reminderH.UpdatePrefs)
@@ -1115,34 +1193,93 @@ func (s *Server) setupRouter() {
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Use(middleware.RequireAdmin(s.userRepo))
r.Get("/stats", s.adminH.Stats)
r.Get("/users", s.adminH.ListUsers)
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
r.Post("/users/{id}/ban", s.adminH.BanUser)
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
// Direct-disciple linking: mark an account as one of Śrīla
// Prabhupāda's initiated disciples (or clear the mark).
r.Get("/disciples", s.adminH.ListDisciples)
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
r.Get("/posts", s.adminH.ListPosts)
r.Post("/posts/{id}/hide", s.adminH.HidePost)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
r.Delete("/posts/{id}", s.adminH.DeletePost)
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
r.Delete("/comments/{id}", s.adminH.DeleteComment)
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
r.Get("/collections", s.adminH.ListCollections)
r.Get("/feedback", s.adminH.ListFeedback)
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
r.Get("/reports", s.adminH.ListReports)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
r.Get("/short-urls", s.adminH.ListShortURLs)
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
// Staff only past this point; each route then names the one
// permission it needs. A route without a permission is reachable
// by every staff member, so only /me is left bare.
r.Use(middleware.RequireStaff(s.staffRepo))
can := func(perm string) chi.Router {
return r.With(middleware.RequirePermission(perm))
}
r.Get("/me", s.staffH.Me)
// Staff and roles — superadmins only.
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
can("stats:read").Get("/stats", s.adminH.Stats)
can("users:read").Get("/users", s.adminH.ListUsers)
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
can("users:read").Get("/disciples", s.adminH.ListDisciples)
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
can("vani:read").Get("/vani", s.vaniH.Schedule)
can("vani:write").Post("/vani", s.vaniH.Set)
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
can("collections:read").Get("/collections", s.adminH.ListCollections)
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
can("feedback: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)
})
})
+5 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap"
)
@@ -335,8 +336,9 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
var buf bytes.Buffer
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
"RecipientName": name,
"Reply": message,
"URL": s.frontendURL,
// Rendered markers; escaped by richtext, newlines become <br>.
"Reply": richtext.ToHTML(message),
"URL": s.frontendURL,
}); err != nil {
s.logger.Warn("render feedback reply email failed", zap.Error(err))
return true, false, nil
@@ -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>
<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">
<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>
+268
View File
@@ -0,0 +1,268 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
var (
ErrBroadcastTitle = errors.New("a title is required")
ErrBroadcastBody = errors.New("a description is required")
ErrBroadcastCTA = errors.New("that CTA link is not a valid URL")
ErrBroadcastBanner = errors.New("that image was not uploaded for a broadcast")
)
const (
maxBroadcastTitleRunes = 200
maxBroadcastBodyRunes = 4000
// bannerPrefix namespaces broadcast banners in the PUBLIC bucket. An
// announcement is not private, and a signed URL would expire while the
// modal sat open on someone's screen.
bannerPrefix = "broadcasts/"
)
// BroadcastChannel is the PUBLIC realtime channel every client watches for
// announcements.
//
// Public is deliberate and safe because the event carries no content — just
// "something was published". Each client then fetches /broadcasts/pending,
// which is authenticated and per-user, so someone who has already dismissed it
// gets nothing. That also keeps publishing to one Soketi call instead of one
// per user.
const BroadcastChannel = "broadcasts"
type BroadcastService struct {
repo *repository.BroadcastRepo
push *PushSender
pushes *repository.PushRepo
prefs *repository.NotificationPrefsRepo
rt *realtime.SoketiClient
r2 *repository.R2
logger *zap.Logger
}
func NewBroadcastService(
repo *repository.BroadcastRepo,
push *PushSender,
pushes *repository.PushRepo,
prefs *repository.NotificationPrefsRepo,
rt *realtime.SoketiClient,
r2 *repository.R2,
logger *zap.Logger,
) *BroadcastService {
return &BroadcastService{
repo: repo, push: push, pushes: pushes, prefs: prefs,
rt: rt, r2: r2, logger: logger,
}
}
// CreateParams is an admin-authored announcement.
type CreateParams struct {
Title string
Body string
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
}
// Create stores a draft. Nothing is delivered until Publish.
func (s *BroadcastService) Create(ctx context.Context, adminID string, p CreateParams) (*model.Broadcast, error) {
title := strings.TrimSpace(p.Title)
body := strings.TrimSpace(p.Body)
if title == "" || len([]rune(title)) > maxBroadcastTitleRunes {
return nil, ErrBroadcastTitle
}
if body == "" || len([]rune(body)) > maxBroadcastBodyRunes {
return nil, ErrBroadcastBody
}
if p.CTAURL != nil && strings.TrimSpace(*p.CTAURL) != "" {
clean, ok := safeCTAURL(*p.CTAURL)
if !ok {
return nil, ErrBroadcastCTA
}
p.CTAURL = &clean
} else {
p.CTAURL = nil
}
if p.BannerKey != nil && !strings.HasPrefix(*p.BannerKey, bannerPrefix) {
return nil, ErrBroadcastBanner
}
// Same parser as the chat composer, so the admin writes *bold* / ~strike~ /
// `code` and links are detected — and no HTML is ever stored.
clean, entities := ParseEntities(body)
encoded, err := json.Marshal(entities)
if err != nil {
return nil, fmt.Errorf("encode entities: %w", err)
}
if entities == nil {
encoded = []byte(`[]`)
}
b, key, err := s.repo.Create(ctx, repository.CreateBroadcastParams{
Title: title, Body: clean, Entities: encoded, BannerKey: p.BannerKey,
CTALabel: p.CTALabel, CTAURL: p.CTAURL, ExpiresAt: p.ExpiresAt,
CreatedBy: adminID,
})
if err != nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// Publish makes a broadcast live, then nudges everyone: connected clients over
// realtime, absent ones by push.
func (s *BroadcastService) Publish(ctx context.Context, id string) (*model.Broadcast, error) {
b, key, err := s.repo.Publish(ctx, id)
if err != nil {
return nil, err
}
s.withBanner(b, key)
// One trigger for the whole user base. Content-free on purpose — clients
// re-fetch their own pending state.
if s.rt != nil {
if err := s.rt.Trigger(ctx, BroadcastChannel, "broadcast_published", map[string]any{}); err != nil {
s.logger.Warn("broadcast: realtime nudge failed", zap.Error(err))
}
}
go s.fanOutPush(context.WithoutCancel(ctx), b)
return b, nil
}
// fanOutPush notifies everyone with a push subscription who hasn't opted out
// of announcements.
//
// Runs detached from the request: a large user base takes far longer than an
// admin should wait on a button.
func (s *BroadcastService) fanOutPush(ctx context.Context, b *model.Broadcast) {
if s.push == nil || !s.push.Enabled() || s.pushes == nil {
return
}
users, err := s.pushes.SubscribedUserIDs(ctx)
if err != nil {
s.logger.Warn("broadcast: listing subscribers failed", zap.Error(err))
return
}
sent := 0
for _, uid := range users {
if s.prefs != nil {
// Reuses the existing "Community" category, described in settings
// as rare announcements — which is exactly this. Someone who
// opted out of those stays opted out.
if off, err := s.prefs.DisabledSet(ctx, uid); err == nil &&
prefOff(off, NotifDiscipleJoined, repository.ChannelPush) {
continue
}
}
s.push.SendToUser(ctx, uid, PushPayload{
Title: b.Title,
Body: truncateRunes(b.Body, 160),
URL: "/",
// One tag per broadcast: a re-publish replaces rather than stacks.
Tag: "broadcast:" + b.ID,
})
sent++
}
s.logger.Info("broadcast: push fan-out complete",
zap.String("broadcast_id", b.ID), zap.Int("recipients", sent))
}
// Pending returns the announcement waiting for this user, or nil.
func (s *BroadcastService) Pending(ctx context.Context, userID string) (*model.Broadcast, error) {
b, key, err := s.repo.Pending(ctx, userID)
if err != nil || b == nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// MarkSeen records that a user dismissed it — the "at most once" half.
func (s *BroadcastService) MarkSeen(ctx context.Context, id, userID string) error {
return s.repo.MarkSeen(ctx, id, userID)
}
func (s *BroadcastService) List(ctx context.Context, limit int) ([]model.Broadcast, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
items, keys, err := s.repo.List(ctx, limit)
if err != nil {
return nil, err
}
for i := range items {
s.withBanner(&items[i], keys[i])
}
return items, nil
}
func (s *BroadcastService) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
// PresignBanner issues an upload URL for a banner image.
func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string) (uploadURL, key string, err error) {
if s.r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
kind, ok := attachmentKindFor(contentType)
if !ok || kind != model.AttachmentImage {
return "", "", ErrChatBadMedia
}
// 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) {
if key == nil || *key == "" || s.r2 == nil {
return
}
url := s.r2.PublicURL(*key)
b.BannerURL = &url
}
// safeCTAURL accepts an in-app path or an absolute http(s) URL.
//
// Admin-authored, so this is a typo guard more than a threat model — but
// `javascript:` and `data:` still have no business in a link the whole user
// base is invited to tap.
func safeCTAURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 2048 {
return "", false
}
if strings.ContainsAny(raw, "\\") || strings.ContainsAny(raw, "\n\r\t") {
return "", false
}
if strings.HasPrefix(raw, "/") {
// Protocol-relative "//evil.example" is not an in-app path.
if strings.HasPrefix(raw, "//") {
return "", false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" {
return "", false
}
return raw, true
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", false
}
return u.String(), true
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"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 testBroadcastService(t *testing.T) (*BroadcastService, *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)
// Nil push/realtime/R2: these tests are about who sees what, and none of
// those services are reachable from a test run.
return NewBroadcastService(repository.NewBroadcastRepo(pg), nil, nil, nil, nil, nil, zap.NewNop()), pg
}
func bcUser(t *testing.T, pg *repository.Postgres, label string) 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', $3, $4)`,
id, "bc-"+suffix+"@example.invalid", "BC "+label, "bc_"+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 cleanupBroadcast(t *testing.T, pg *repository.Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM broadcasts WHERE id = $1`, id)
})
}
// "At least and at most once" is the whole contract. A user must be offered a
// published announcement until they dismiss it, and never again after.
func TestBroadcast_ShownExactlyOncePerUser(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, alice, bob := bcUser(t, pg, "Admin"), bcUser(t, pg, "A"), bcUser(t, pg, "B")
b, err := svc.Create(ctx, admin, CreateParams{
Title: "New: chat", Body: "Direct messages are here.",
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
// A draft is nobody's business yet.
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Fatalf("an unpublished draft was offered: %+v", got)
}
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
// At least once: offered, and still offered until dismissed.
for i := 0; i < 2; i++ {
got, err := svc.Pending(ctx, alice)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != b.ID {
t.Fatalf("call %d: expected the published announcement, got %+v", i+1, got)
}
}
// At most once: dismissed means gone, and dismissing twice is harmless.
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Fatal(err)
}
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Errorf("second dismissal should be idempotent, got %v", err)
}
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Errorf("shown again after dismissal: %+v", got)
}
// One person dismissing must not dismiss it for everyone.
if got, _ := svc.Pending(ctx, bob); got == nil || got.ID != b.ID {
t.Errorf("another user lost the announcement: %+v", got)
}
}
// An announcement past its expiry stops being offered, so someone signing up
// months later isn't shown last quarter's news.
func TestBroadcast_ExpiryStopsDelivery(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
past := time.Now().Add(-time.Hour)
b, err := svc.Create(ctx, admin, CreateParams{
Title: "Old news", Body: "Yesterday's announcement.", ExpiresAt: &past,
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
if got, _ := svc.Pending(ctx, reader); got != nil {
t.Errorf("an expired announcement was offered: %+v", got)
}
}
// Only the newest announcement is offered: two modals stacked on launch is a
// worse experience than missing the older one.
func TestBroadcast_OffersNewestFirst(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
older, err := svc.Create(ctx, admin, CreateParams{Title: "First", Body: "one"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, older.ID)
if _, err := svc.Publish(ctx, older.ID); err != nil {
t.Fatal(err)
}
newer, err := svc.Create(ctx, admin, CreateParams{Title: "Second", Body: "two"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, newer.ID)
if _, err := svc.Publish(ctx, newer.ID); err != nil {
t.Fatal(err)
}
got, err := svc.Pending(ctx, reader)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != newer.ID {
t.Errorf("expected the newest announcement, got %+v", got)
}
}
+806 -22
View File
@@ -1,18 +1,22 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/url"
"path"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"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/unfurl"
"go.uber.org/zap"
@@ -41,6 +45,9 @@ var (
ErrAvatarUnset = errors.New("this group has no photo")
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
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")
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")
)
@@ -60,23 +67,43 @@ const (
type ChatService struct {
chat *repository.ChatRepo
blocks *repository.BlockRepo
rt *realtime.SoketiClient
r2 *repository.R2
links *unfurl.Fetcher
cache *repository.Redis
logger *zap.Logger
// posts resolves shared post cards. Optional: nil leaves entity
// attachments unresolved rather than failing a whole thread load.
posts *repository.PostRepo
// push, prefs and users deliver out-of-band message alerts. All optional:
// nil means no push, never a failed send.
push *PushSender
prefs *repository.NotificationPrefsRepo
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
r2 *repository.R2
links *unfurl.Fetcher
cache *repository.Redis
logger *zap.Logger
}
func NewChatService(
chat *repository.ChatRepo,
blocks *repository.BlockRepo,
posts *repository.PostRepo,
push *PushSender,
prefs *repository.NotificationPrefsRepo,
users *repository.UserRepo,
mail mailer.Mailer,
frontendURL string,
rt *realtime.SoketiClient,
r2 *repository.R2,
cache *repository.Redis,
logger *zap.Logger,
) *ChatService {
return &ChatService{
chat: chat, blocks: blocks, rt: rt, r2: r2,
chat: chat, blocks: blocks, posts: posts,
push: push, prefs: prefs, users: users, mail: mail,
frontendURL: frontendURL, rt: rt, r2: r2,
links: unfurl.New(), cache: cache, logger: logger,
}
}
@@ -149,6 +176,11 @@ func (s *ChatService) Inbox(ctx context.Context, userID, tab string, limit int)
return s.chat.Inbox(ctx, userID, KindsForTab(tab), limit)
}
// UnreadCount reports how many threads are waiting on the caller.
func (s *ChatService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.chat.UnreadConversations(ctx, userID)
}
// OpenDM returns (creating if needed) the DM between the caller and peer.
//
// Blocking is enforced in BOTH directions: a blocked user must not be able to
@@ -315,11 +347,12 @@ func (s *ChatService) Starred(ctx context.Context, userID, conversationID string
if err != nil {
return nil, err
}
return s.hydrateAttachments(ctx, msgs)
return s.hydrateAttachments(ctx, userID, msgs)
}
// hydrateAttachments fills in each message's attachments in one query.
func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Message) ([]model.Message, error) {
// hydrateAttachments fills in each message's attachments in one query, then
// resolves any shared entities for this particular viewer.
func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, msgs []model.Message) ([]model.Message, error) {
ids := make([]string, 0, len(msgs))
for i := range msgs {
ids = append(ids, msgs[i].ID)
@@ -328,6 +361,12 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Messa
if err != nil {
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 {
if a := byMessage[msgs[i].ID]; a != nil {
msgs[i].Attachments = a
@@ -335,7 +374,17 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Messa
// Always a slice, never nil, so clients need no guard.
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))
for i := range msgs {
pages = append(pages, msgs[i].Attachments)
}
s.resolveEntities(ctx, viewerID, pages)
return msgs, nil
}
@@ -362,7 +411,7 @@ func (s *ChatService) Messages(ctx context.Context, userID, conversationID strin
return nil, err
}
return s.hydrateAttachments(ctx, msgs)
return s.hydrateAttachments(ctx, userID, msgs)
}
// SendRequest is one outgoing message from a client.
@@ -377,6 +426,14 @@ type SendRequest struct {
// fetches the title, description and image itself, because a client that
// could supply them could make any link claim to be anything.
PreviewURL *string
// Entity is something else in the app — a post — shared as a card.
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
// reader can tap to return to the exact phrase.
Quote *QuoteAttachment
// Attachments are files already uploaded to the private bucket via
// PresignAttachment; their keys are checked against the caller's prefix.
Attachments []MediaAttachment
@@ -407,10 +464,15 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
return nil, err
}
if req.Quote != nil && !req.Quote.valid() {
return nil, ErrChatBadQuote
}
body := strings.TrimSpace(req.Body)
// A photo with no caption is an ordinary message; only a message with
// neither text nor attachments is empty.
if body == "" && len(req.Attachments) == 0 {
// A photo or a shared passage with no caption is an ordinary message; only
// a message with nothing at all in it is empty.
if body == "" && len(req.Attachments) == 0 && req.Quote == nil &&
req.Entity == nil && req.StickerKey == nil {
return nil, ErrChatEmptyBody
}
if len([]rune(body)) > maxBodyRunes {
@@ -442,22 +504,51 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
// the first time, and re-publishing would duplicate the bubble for
// everyone else.
if !created {
return s.withAttachments(ctx, 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 {
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
}
s.attachQuote(ctx, conversationID, msg, req.Quote)
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
full, err := s.withAttachments(ctx, msg)
if err != nil {
full = msg
}
full := s.hydrateSent(ctx, userID, conversationID, msg)
s.publish(ctx, conversationID, userID, full)
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.
func (s *ChatService) withAttachments(ctx context.Context, 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})
if err != nil {
return msg, err
@@ -466,6 +557,13 @@ func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (
if msg.Attachments == nil {
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})
return msg, nil
}
@@ -499,6 +597,262 @@ func (s *ChatService) attachMedia(ctx context.Context, conversationID, userID st
}
}
// EntityAttachment is a reference to something else in the app, shared into a
// chat. Only posts for now; the column allows comments, profiles and
// collections when their cards are built.
type EntityAttachment struct {
Type string
ID string
}
// postSnapshot is the copy stored alongside the reference.
//
// It is a FALLBACK, never the source of truth: resolveEntities re-reads the
// live post for each viewer, because a post can be deleted, hidden, or become
// invisible to one participant after it was shared. Rendering the snapshot
// unconditionally would leak a post to someone who has since lost access.
type postSnapshot struct {
ID string `json:"id"`
AuthorHandle string `json:"author_handle"`
AuthorName string `json:"author_name"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
Caption string `json:"caption"`
ImageURL *string `json:"image_url,omitempty"`
SourceTitle string `json:"source_title,omitempty"`
}
func snapshotOf(p *model.Post) postSnapshot {
snap := postSnapshot{
ID: p.ID, AuthorHandle: p.AuthorHandle, AuthorName: p.AuthorName,
AuthorAvatarURL: p.AuthorAvatarURL, Caption: p.Caption,
SourceTitle: p.SourceTitle,
}
if len(p.Images) > 0 {
url := p.Images[0].URL
snap.ImageURL = &url
}
return snap
}
// postVisibleTo decides whether a viewer may see a post inside a chat bubble.
//
// Same rules the feed applies: a hidden post is the owner's alone, and a block
// in either direction hides the author's content.
func (s *ChatService) postVisibleTo(ctx context.Context, viewerID string, p *model.Post) bool {
if p.IsHidden && p.UserID != viewerID {
return false
}
if p.UserID == viewerID {
return true
}
blocked, blockedBy, err := s.blocks.BlockState(ctx, viewerID, p.UserID)
if err != nil {
// Fail closed: an unreadable block table must not open a post up.
return false
}
return !blocked && !blockedBy
}
// attachEntity stores a shared post after checking the SENDER can see it —
// you cannot forward what you were never allowed to read.
func (s *ChatService) attachEntity(ctx context.Context, conversationID, userID string, msg *model.Message, e *EntityAttachment) error {
if e == nil {
return nil
}
if e.Type != model.EntityPost {
return ErrChatBadEntity
}
post, err := s.posts.FindByID(ctx, e.ID)
if err != nil || post == nil {
return ErrChatBadEntity
}
if !s.postVisibleTo(ctx, userID, post) {
return ErrChatBadEntity
}
snap, err := json.Marshal(snapshotOf(post))
if err != nil {
return ErrChatBadEntity
}
entityType, entityID := model.EntityPost, post.ID
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
Kind: model.AttachmentEntity, EntityType: &entityType,
EntityID: &entityID, Snapshot: snap,
}); err != nil {
s.logger.Warn("chat: attach entity failed", zap.Error(err))
}
return nil
}
// resolveEntities re-reads every shared entity on a page for THIS viewer.
//
// This is the permission re-check that makes sharing safe: access is decided
// at read time, per person, not frozen at send time. A post that was visible
// when shared and is hidden now renders as unavailable.
//
// It takes the whole page at once — three queries (posts, their images,
// blocks) regardless of how many shares are on it. Resolving per message
// instead would cost two queries per share and could not dedupe a post quoted
// in several messages.
func (s *ChatService) resolveEntities(ctx context.Context, viewerID string, pages [][]model.Attachment) {
if s.posts == nil {
return
}
// Gather the distinct posts referenced anywhere on the page.
shared := map[string]bool{}
for _, atts := range pages {
for i := range atts {
if atts[i].Kind == model.AttachmentEntity && atts[i].EntityID != nil {
shared[*atts[i].EntityID] = true
}
}
}
if len(shared) == 0 {
return
}
ids := make([]string, 0, len(shared))
for id := range shared {
ids = append(ids, id)
}
posts, err := s.posts.FindByIDs(ctx, ids)
if err != nil {
// Fail closed: every card becomes a tombstone rather than falling back
// to snapshots whose visibility we could not confirm.
s.logger.Warn("chat: resolving shared posts failed", zap.Error(err))
posts = nil
}
authors := make([]string, 0, len(posts))
for _, post := range posts {
if post.UserID != viewerID {
authors = append(authors, post.UserID)
}
}
blocked, err := s.blocks.BlockedWith(ctx, viewerID, authors)
if err != nil {
s.logger.Warn("chat: block lookup for shared posts failed", zap.Error(err))
// Fail closed again: an unreadable block table must not open posts up.
blocked = nil
for _, id := range authors {
if blocked == nil {
blocked = map[string]bool{}
}
blocked[id] = true
}
}
// Resolve each referenced post once, then fan the result out.
resolved := make(map[string]json.RawMessage, len(shared))
for id := range shared {
post := posts[id]
if post == nil {
continue
}
if post.IsHidden && post.UserID != viewerID {
continue
}
if blocked[post.UserID] {
continue
}
if b, mErr := json.Marshal(snapshotOf(post)); mErr == nil {
resolved[id] = b
}
}
for _, atts := range pages {
for i := range atts {
a := &atts[i]
if a.Kind != model.AttachmentEntity || a.EntityID == nil {
continue
}
// The stored snapshot is never sent to a client — only a copy
// resolved for this viewer — so a stale one cannot leak.
a.Entity = resolved[*a.EntityID]
a.EntityUnavailable = a.Entity == nil
}
}
}
// QuoteAttachment is a passage shared out of the library.
type QuoteAttachment struct {
// Path is an APP-RELATIVE deep link, e.g. "/books/bg/2/13?hl=…".
//
// Deliberately not a full URL. A quote renders as trusted in-app content,
// so allowing an absolute URL would let a sender dress an off-site link up
// as a scripture citation. A path cannot leave the app, which removes the
// question of which origins to trust.
Path string
// Title is the source reference shown above the passage.
Title string
// Text is the quoted passage itself.
Text string
}
const (
maxQuoteTitleRunes = 200
maxQuoteTextRunes = 2000
maxQuotePathBytes = 2048
)
func (q *QuoteAttachment) valid() bool {
if strings.TrimSpace(q.Text) == "" {
return false
}
if len([]rune(q.Title)) > maxQuoteTitleRunes || len([]rune(q.Text)) > maxQuoteTextRunes {
return false
}
return safeAppPath(q.Path)
}
// safeAppPath accepts only a path within this app.
//
// "//evil.com" and "/\evil.com" are protocol-relative and would navigate
// off-site, so a leading slash alone is not enough to check.
func safeAppPath(p string) bool {
if p == "" || len(p) > maxQuotePathBytes {
return false
}
if p[0] != '/' {
return false
}
if strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
return false
}
if strings.ContainsAny(p, "\\") {
return false
}
for _, r := range p {
if r < 0x20 || r == 0x7f {
return false
}
}
// A parsed path must not carry a scheme or host of its own.
u, err := url.Parse(p)
if err != nil || u.Scheme != "" || u.Host != "" {
return false
}
return true
}
// attachQuote stores a shared passage. Unlike a link card, the contents come
// from the client — but they are only ever rendered as a quotation, and the
// path has already been constrained to this app.
func (s *ChatService) attachQuote(ctx context.Context, conversationID string, msg *model.Message, q *QuoteAttachment) {
if q == nil {
return
}
path, title, text := q.Path, strings.TrimSpace(q.Title), strings.TrimSpace(q.Text)
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
Kind: model.AttachmentQuote, URL: &path, Title: &title, Description: &text,
}); err != nil {
s.logger.Warn("chat: attach quote failed", zap.Error(err))
}
}
// attachLinkPreview unfurls the chosen link and stores the card.
//
// Two rules make this safe. The URL must be one the parser actually found in
@@ -736,10 +1090,14 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC
return nil, err
}
if created {
full, hErr := s.withAttachments(ctx, msg)
if hErr == nil {
msg = full
// Carry the attachments across. Without this a quote, sticker, photo
// or shared post forwards as an empty bubble: its content lives in an
// 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)
}
return msg, nil
@@ -821,6 +1179,14 @@ func (s *ChatService) systemMessage(ctx context.Context, conversationID, body st
return
}
if created {
// Publish the hydrated message. Append leaves Attachments nil, which
// serialises to null and breaks clients that trust the "always an
// array" contract this field's own doc comment promises.
if full, hErr := s.withAttachments(ctx, "", msg); hErr == nil {
msg = full
} else if msg.Attachments == nil {
msg.Attachments = []model.Attachment{}
}
s.publish(ctx, conversationID, "", msg)
}
}
@@ -1247,6 +1613,191 @@ func (s *ChatService) IsMuted(ctx context.Context, userID, conversationID string
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
// draws the caller's tick states.
//
@@ -1296,6 +1847,13 @@ func (s *ChatService) Receipts(ctx context.Context, userID, conversationID strin
// publish fans a new message out to the open thread, and separately nudges each
// other participant's inbox.
func (s *ChatService) publish(ctx context.Context, conversationID, senderID string, msg *model.Message) {
// Out-of-band alerts for anyone not currently connected. Best-effort and
// after the realtime fan-out, so a slow push never delays delivery to
// people who are online.
if conv, err := s.chat.Conversation(ctx, conversationID); err == nil {
s.notifyNewMessage(ctx, conv, senderID, msg)
}
if s.rt == nil {
return
}
@@ -1330,6 +1888,232 @@ func (s *ChatService) publish(ctx context.Context, conversationID, senderID stri
}
}
// pushConfig tunes out-of-band delivery.
const (
// One push per thread per window, however many messages arrive. A burst
// should buzz a pocket once.
pushDebounceWindow = 90 * time.Second
// How long a live WebSocket connection suppresses push for.
presenceTTL = 2 * time.Minute
// Lock-screen previews are deliberately senderless of content — see
// notifyNewMessage.
maxPushPreviewRunes = 120
)
// presenceKey marks "this user has a live realtime connection".
func presenceKey(userID string) string { return "chat:online:" + userID }
// MarkPresence records that a user's realtime channel became occupied or
// vacated. Driven by Soketi's webhooks.
func (s *ChatService) MarkPresence(ctx context.Context, userID string, online bool) {
if s.cache == nil {
return
}
if online {
s.cache.Client.Set(ctx, presenceKey(userID), "1", presenceTTL)
return
}
s.cache.Client.Del(ctx, presenceKey(userID))
}
// notifyNewMessage delivers a Web Push alert to participants who are not
// currently connected.
//
// Deliberately NOT routed through NotificationService: chat must not create a
// notifications row or land in the tray. It borrows only the preference matrix
// so "Messages → Push" can be turned off like anything else.
//
// Four things suppress a push, in the order that costs least to check:
// the thread is muted, the user opted out, they have a live connection, or one
// already went out for this thread recently.
func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) {
if s.push == nil || !s.push.Enabled() {
return
}
candidates, err := s.chat.PushCandidates(ctx, conv.ID, senderID)
if err != nil {
s.logger.Warn("chat: push candidates failed", zap.Error(err))
return
}
title, body := s.pushCopy(ctx, conv, senderID, msg)
now := time.Now()
for _, c := range candidates {
if c.MutedUntil != nil && c.MutedUntil.After(now) {
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 off, err := s.prefs.DisabledSet(ctx, userID); err == nil &&
prefOff(off, NotifChatMessage, repository.ChannelPush) {
return
}
}
if s.online(ctx, userID) {
return
}
if !s.claimPushSlot(ctx, userID, conv.ID) {
return
}
s.push.SendToUser(ctx, userID, PushPayload{
Title: title,
Body: body,
URL: "/chats/" + conv.ID,
// Same tag per thread: a later notification replaces the earlier one
// rather than stacking up.
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.
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
sender := "Someone"
if s.users != nil {
if u, err := s.users.FindByID(ctx, senderID); err == nil && u != nil {
if u.Name != "" {
sender = u.Name
} else if u.Handle != "" {
sender = "@" + u.Handle
}
}
}
title = sender
if conv.Kind == model.ConversationGroup && conv.Title != nil && *conv.Title != "" {
title = sender + " · " + *conv.Title
}
switch {
case strings.TrimSpace(msg.Body) != "":
body = truncateRunes(strings.TrimSpace(msg.Body), maxPushPreviewRunes)
case len(msg.Attachments) > 0:
body = "Sent an attachment"
default:
body = "Sent you a message"
}
return title, body
}
// online reports whether the user holds a live realtime connection.
//
// Best-effort and fails OPEN: if the cache is unavailable — or Soketi is not
// configured to send channel_occupied — we treat them as offline and push. A
// redundant notification while they are reading is a smaller failure than a
// message that never arrives.
func (s *ChatService) online(ctx context.Context, userID string) bool {
if s.cache == nil {
return false
}
n, err := s.cache.Client.Exists(ctx, presenceKey(userID)).Result()
return err == nil && n > 0
}
// claimPushSlot returns true for the first push to a thread within the window.
// Fails OPEN for the same reason as online().
func (s *ChatService) claimPushSlot(ctx context.Context, userID, conversationID string) bool {
if s.cache == nil {
return true
}
ok, err := s.cache.Client.SetNX(ctx,
"chat:pushed:"+userID+":"+conversationID, "1", pushDebounceWindow).Result()
if err != nil {
return true
}
return ok
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
func (s *ChatService) publishReceipt(ctx context.Context, conversationID, userID, kind string, seq int64) {
if s.rt == nil {
return
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
+34 -26
View File
@@ -21,24 +21,23 @@ import (
)
type FeedbackService struct {
repo *repository.FeedbackRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
repo *repository.FeedbackRepo
// categories is the admin-managed list a submission must belong to.
categories *repository.FeedbackCategoryRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger}
}
// Allowed categories — kept lowercase in storage so dashboards group
// cleanly. The frontend dropdown sends one of these exact strings.
var feedbackCategories = map[string]bool{
"bug": true,
"feature": true,
"general": true,
"other": true,
}
// ErrFeedbackCategory: the submission names a category that doesn't exist
// or has been archived in the admin panel.
var ErrFeedbackCategory = errors.New("invalid category")
// 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).
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
category = strings.ToLower(strings.TrimSpace(category))
if !feedbackCategories[category] {
return nil, errors.New("invalid category")
if ok, err := s.categories.IsActive(ctx, category); err != nil {
return nil, err
} else if !ok {
return nil, ErrFeedbackCategory
}
message = strings.TrimSpace(message)
if message == "" {
@@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
return nil, err
}
// Hydrate the joined author fields for the email template. Failing
// the lookup isn't worth failing the request — we fall back to
// "anonymous" rather than 500ing on the user.
if userID != nil {
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full
} else {
s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err))
}
// Hydrate the joined author fields and category label for the email
// template. Failing the lookup isn't worth failing the request — we fall
// back to "anonymous" and the category key rather than 500ing.
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full
} else {
s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err))
f.CategoryLabel = f.Category
}
s.notifyAdmins(f)
@@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
s.logger.Warn("render feedback email failed", zap.Error(err))
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 {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("feedback email send failed",
@@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
}
var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category,
"Category": categoryLabel(f),
"Message": f.Message,
"From": from,
"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
}
// 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")
}
}
}
+16 -5
View File
@@ -26,6 +26,7 @@ import (
"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/richtext"
"go.uber.org/zap"
)
@@ -44,6 +45,11 @@ const (
// disciples. The actor is the disciple, so it renders and links exactly
// like the other actor-driven kinds.
NotifDiscipleJoined = "disciple_joined"
// NotifChatMessage exists ONLY as a preference key. Chat never creates a
// notifications row and never appears in the tray — a new message
// highlights its inbox row instead. The kind is here so the settings
// matrix can offer a push opt-out alongside everything else.
NotifChatMessage = "chat_message"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
@@ -60,6 +66,9 @@ var PrefCategories = map[string][]string{
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
"community": {NotifDiscipleJoined},
// Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
// In-app delivery for chat is the inbox itself, so it isn't offered here.
"messages": {NotifChatMessage},
}
// PrefChannels is every delivery channel a matrix cell can address.
@@ -292,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
Body: richtext.Plain(body),
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
@@ -634,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if s.rt != nil {
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
"kind": "admin_message",
"body": body,
"body": richtext.Plain(body),
}); err != nil {
s.logger.Warn("realtime admin message publish failed",
zap.String("recipient", recipientID), zap.Error(err))
@@ -662,8 +671,10 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Title": strings.TrimSpace(title),
"Body": body,
"URL": url,
// 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,
}); err != nil {
s.logger.Warn("render admin message email failed", zap.Error(err))
} else {
@@ -874,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}}
<p style="white-space:pre-line">{{.Body}}</p>
<p>{{.Body}}</p>
<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>
+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)
}
}
+95 -2
View File
@@ -16,7 +16,12 @@ package service
import (
"context"
crand "crypto/rand"
"encoding/hex"
"errors"
"math/rand"
"regexp"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
@@ -64,7 +69,10 @@ type SadhnaService struct {
posts *repository.PostRepo
users *repository.UserRepo
rewards *repository.RewardRepo
logger *zap.Logger
// r2 stores custom gift artwork. Optional: nil disables custom gifts
// rather than failing anything that already works.
r2 *repository.R2
logger *zap.Logger
}
func NewSadhnaService(
@@ -72,9 +80,13 @@ func NewSadhnaService(
posts *repository.PostRepo,
users *repository.UserRepo,
rewards *repository.RewardRepo,
r2 *repository.R2,
logger *zap.Logger,
) *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"
@@ -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
// 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 {
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
// given prefix. The random suffix invalidates browser caches when the image
// 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) {
if r2 == nil {
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 (
"context"
"errors"
"fmt"
"math/rand"
"net/url"
"regexp"
"strconv"
"strings"
"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
// first call of the day. Returns (nil, nil) only if there's genuinely no
// 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) {
now := time.Now().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
// 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 {
kinds := []string{"verse", "prose", "transcript"}
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
@@ -0,0 +1,8 @@
-- Rows of the new kind must go before the old constraint can be restored.
DELETE FROM message_attachments WHERE kind = 'quote';
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'));
+18
View File
@@ -0,0 +1,18 @@
-- A shared passage from the library, sent into a chat.
--
-- Modelled as an attachment rather than message text so it can be rendered as
-- a quote and navigated back to. It reuses the columns the link card already
-- has, since the shape is the same thing said differently:
--
-- url → the ?hl= deep link back to the exact phrase
-- title → the source reference ("Bhagavad-gītā 2.13")
-- description → the quoted passage itself
--
-- It is a distinct kind rather than a 'link' because it renders as a quote,
-- not a preview card, and because its URL is always our own — the service
-- refuses any other origin.
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'));
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS broadcast_views;
DROP TABLE IF EXISTS broadcasts;
+44
View File
@@ -0,0 +1,44 @@
-- Admin broadcasts — product updates and announcements, shown once per user.
--
-- On "no history": these deliberately create no notification rows and never
-- appear in the tray. But "exactly once per user" cannot be done without
-- storing something — the app has to know whether YOU have seen it. So two
-- tables: the announcement, and who has dismissed it. Nothing per-user is
-- written until that person actually sees it.
CREATE TABLE IF NOT EXISTS broadcasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL DEFAULT '',
-- Formatting as offset/length spans, exactly like chat messages: the same
-- parser produces them and the same component renders them, so no HTML is
-- stored and nothing needs sanitising at render time.
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
-- R2 key for the banner. Public bucket: an announcement is not private,
-- and a signed URL would expire while the modal sat open.
banner_key TEXT,
cta_label VARCHAR(60),
-- App-relative path or absolute http(s) URL; validated in the service.
cta_url TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- NULL = draft. Nothing is delivered until this is set.
published_at TIMESTAMPTZ,
-- After this, the announcement stops being offered. This is what keeps a
-- months-old update from greeting someone who signs up tomorrow.
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "What is live right now?" — the app's pending lookup and the admin list.
CREATE INDEX IF NOT EXISTS broadcasts_live_idx
ON broadcasts (published_at DESC) WHERE published_at IS NOT NULL;
-- One row per person who has seen an announcement. Written on dismissal, which
-- is what makes "at most once" true across devices — localStorage would show
-- it again on every new browser.
CREATE TABLE IF NOT EXISTS broadcast_views (
broadcast_id UUID NOT NULL REFERENCES broadcasts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (broadcast_id, user_id)
);
@@ -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)
}
}
}