Compare commits
10
Commits
9c458ba19f
...
4b38047c26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b38047c26 | ||
|
|
7d7ba2a002 | ||
|
|
6633d0cca3 | ||
|
|
0fee56d272 | ||
|
|
44fd1d69ab | ||
|
|
ac92bc2cf3 | ||
|
|
a2d22ed829 | ||
|
|
4427849647 | ||
|
|
fecb9bc045 | ||
|
|
cee2d3c874 |
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
|
|||||||
|
|
||||||
# Frontend (CORS + email link base)
|
# Frontend (CORS + email link base)
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
|
||||||
|
ADMIN_URL=
|
||||||
|
|
||||||
GOOGLE_OAUTH_CLIENT_ID=
|
GOOGLE_OAUTH_CLIENT_ID=
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
@@ -15,9 +17,15 @@ import (
|
|||||||
|
|
||||||
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||||
// the router, so every method here can assume an admin caller.
|
// the router, so every method here can assume an admin caller.
|
||||||
type AdminHandler struct{ svc *service.AdminService }
|
type AdminHandler struct {
|
||||||
|
svc *service.AdminService
|
||||||
|
// staff guards actions that would otherwise reach other staff members.
|
||||||
|
staff *repository.StaffRepo
|
||||||
|
}
|
||||||
|
|
||||||
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
|
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
|
||||||
|
return &AdminHandler{svc: svc, staff: staff}
|
||||||
|
}
|
||||||
|
|
||||||
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||||
func pageParams(r *http.Request) (q string, limit, offset int) {
|
func pageParams(r *http.Request) (q string, limit, offset int) {
|
||||||
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||||
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
|
target := chi.URLParam(r, "id")
|
||||||
|
// A banned account loses panel access, so banning a staff member is a way
|
||||||
|
// to remove them — which only superadmins may do. Without this, an Admin
|
||||||
|
// holding users:ban could ban a Superadmin out of the panel.
|
||||||
|
if _, err := h.staff.Access(r.Context(), target); err == nil {
|
||||||
|
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
|
||||||
|
response.Error(w, http.StatusForbidden,
|
||||||
|
"staff accounts are managed from the Staff page by a superadmin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == middleware.UserID(r.Context()) {
|
||||||
|
response.Error(w, http.StatusConflict, "you can't ban yourself")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "could not update account")
|
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func writeChatError(w http.ResponseWriter, err error) {
|
|||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadEntity):
|
case errors.Is(err, service.ErrChatBadEntity):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrStickerNotOwned):
|
||||||
|
response.Error(w, http.StatusForbidden, "that sticker isn't in your collection")
|
||||||
|
case errors.Is(err, service.ErrChatBadEmoji):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadQuote):
|
case errors.Is(err, service.ErrChatBadQuote):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadAvatarKey):
|
case errors.Is(err, service.ErrChatBadAvatarKey):
|
||||||
@@ -204,6 +208,8 @@ type sendReq struct {
|
|||||||
// quote is a passage shared from the library. `path` must be app-relative;
|
// quote is a passage shared from the library. `path` must be app-relative;
|
||||||
// the service refuses anything that could navigate off-site.
|
// the service refuses anything that could navigate off-site.
|
||||||
Quote *quoteReq `json:"quote"`
|
Quote *quoteReq `json:"quote"`
|
||||||
|
// sticker_key sends reward artwork. Ownership is checked server-side.
|
||||||
|
StickerKey *string `json:"sticker_key"`
|
||||||
// entity shares something else in the app — a post — as a card. Only the
|
// entity shares something else in the app — a post — as a card. Only the
|
||||||
// reference; the BFF resolves the contents and re-checks visibility.
|
// reference; the BFF resolves the contents and re-checks visibility.
|
||||||
Entity *entityReq `json:"entity"`
|
Entity *entityReq `json:"entity"`
|
||||||
@@ -247,6 +253,7 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
|
|||||||
ReplyToID: req.ReplyToID,
|
ReplyToID: req.ReplyToID,
|
||||||
PreviewURL: req.PreviewURL,
|
PreviewURL: req.PreviewURL,
|
||||||
Quote: quote,
|
Quote: quote,
|
||||||
|
StickerKey: req.StickerKey,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Attachments: media,
|
Attachments: media,
|
||||||
})
|
})
|
||||||
@@ -648,6 +655,110 @@ func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
|
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message — GET /chat/conversations/{id}/messages/{messageID}
|
||||||
|
func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) {
|
||||||
|
msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions
|
||||||
|
//
|
||||||
|
// Only read when someone opens the list; the thread itself renders from the
|
||||||
|
// grouped counts that ride along with each message.
|
||||||
|
func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.MessageReactions(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "messageID"))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stickers — GET /chat/stickers. What the caller may send.
|
||||||
|
func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveStickerReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker
|
||||||
|
//
|
||||||
|
// Keyed to the message it was sent on, not just the sticker key: that is what
|
||||||
|
// proves the caller was actually sent it rather than guessing at the catalog.
|
||||||
|
func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req saveStickerReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "messageID"), req.Key); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing — POST /chat/conversations/{id}/typing
|
||||||
|
//
|
||||||
|
// Fire-and-forget: nothing is stored and the response carries nothing, because
|
||||||
|
// a typing indicator is stale within seconds of being sent.
|
||||||
|
func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Typing(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type reactReq struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// React — PUT /chat/conversations/{id}/messages/{messageID}/reaction.
|
||||||
|
// Replaces any previous choice.
|
||||||
|
func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reactReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The message id is authoritative: React re-derives the conversation from
|
||||||
|
// it and authorizes there, so the id in the path can't be used to react
|
||||||
|
// into a thread the caller isn't in.
|
||||||
|
if err := h.svc.React(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "messageID"), req.Emoji); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction.
|
||||||
|
// Idempotent.
|
||||||
|
func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "messageID")); err != nil {
|
||||||
|
writeChatError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
// Receipts — GET /chat/conversations/{id}/receipts
|
// Receipts — GET /chat/conversations/{id}/receipts
|
||||||
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
|
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
|
||||||
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuizHandler serves quiz authoring (admin) and quiz taking (users).
|
||||||
|
type QuizHandler struct {
|
||||||
|
svc *service.QuizService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuizHandler(svc *service.QuizService) *QuizHandler { return &QuizHandler{svc: svc} }
|
||||||
|
|
||||||
|
func writeQuizError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrQuizNotFound), errors.Is(err, service.ErrQuizUnpublished):
|
||||||
|
response.Error(w, http.StatusNotFound, "quiz not found")
|
||||||
|
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
|
||||||
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
|
||||||
|
errors.Is(err, service.ErrQuizLocked):
|
||||||
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
|
||||||
|
errors.Is(err, service.ErrQuizNoCorrect),
|
||||||
|
errors.Is(err, service.ErrQuizTitle), errors.Is(err, service.ErrQuizNoQuestions),
|
||||||
|
errors.Is(err, service.ErrQuizTooMany), errors.Is(err, service.ErrQuizPrompt),
|
||||||
|
errors.Is(err, service.ErrQuizOptionCount), errors.Is(err, service.ErrQuizOptionBody),
|
||||||
|
errors.Is(err, service.ErrQuizOneCorrect), errors.Is(err, service.ErrQuizTarget):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "quiz request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type quizOptionReq struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
IsCorrect bool `json:"is_correct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type quizQuestionReq struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
Multi bool `json:"multi"`
|
||||||
|
Options []quizOptionReq `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type quizReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
Questions []quizQuestionReq `json:"questions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q quizReq) draft() service.QuizDraft {
|
||||||
|
d := service.QuizDraft{
|
||||||
|
Title: q.Title, Description: q.Description,
|
||||||
|
TargetURL: q.TargetURL, TargetTitle: q.TargetTitle,
|
||||||
|
}
|
||||||
|
for _, qq := range q.Questions {
|
||||||
|
in := repository.QuestionInput{Prompt: qq.Prompt, Explanation: qq.Explanation, Multi: qq.Multi}
|
||||||
|
for _, o := range qq.Options {
|
||||||
|
in.Options = append(in.Options, repository.OptionInput{Body: o.Body, IsCorrect: o.IsCorrect})
|
||||||
|
}
|
||||||
|
d.Questions = append(d.Questions, in)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.ListAdmin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) AdminGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, err := h.svc.GetAdmin(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req quizReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), req.draft())
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req quizReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q, err := h.svc.Update(r.Context(), chi.URLParam(r, "id"), req.draft())
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) setPublished(w http.ResponseWriter, r *http.Request, on bool) {
|
||||||
|
q, err := h.svc.SetPublished(r.Context(), chi.URLParam(r, "id"), on)
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Publish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
|
||||||
|
func (h *QuizHandler) Unpublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, false) }
|
||||||
|
|
||||||
|
func (h *QuizHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Participants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.Participants(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Responses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.svc.Responses(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.svc.ParticipantResult(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ForPath — GET /quizzes/for?path=… Optional auth: signed-out readers still
|
||||||
|
// see that a quiz exists; signed-in ones also get their progress. Returns
|
||||||
|
// null when the page has no quiz.
|
||||||
|
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
||||||
|
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.ListPublished(r.Context(), middleware.UserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
card, err := h.svc.Card(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||||
|
play, err := h.svc.Start(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, play)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuizHandler) Answer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
QuestionID string `json:"question_id"`
|
||||||
|
OptionIDs []string `json:"option_ids"`
|
||||||
|
// Single-option form, still accepted from older clients.
|
||||||
|
OptionID string `json:"option_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.QuestionID == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.OptionIDs) == 0 && req.OptionID != "" {
|
||||||
|
req.OptionIDs = []string{req.OptionID}
|
||||||
|
}
|
||||||
|
if len(req.OptionIDs) == 0 {
|
||||||
|
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
play, err := h.svc.Answer(r.Context(), middleware.UserID(r.Context()),
|
||||||
|
chi.URLParam(r, "id"), req.QuestionID, req.OptionIDs)
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, play)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result — GET /quizzes/{id}/result. The caller's own; the user id is taken
|
||||||
|
// from the session, so there is no way to ask for anyone else's.
|
||||||
|
func (h *QuizHandler) Result(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.svc.Result(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeQuizError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -114,6 +118,110 @@ func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
|
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
|
||||||
// client has shown the confetti, so a gift surfaces exactly once.
|
// client has shown the confetti, so a gift surfaces exactly once.
|
||||||
|
// OpenGift — POST /sadhna/rewards/gifts/{key}/open
|
||||||
|
//
|
||||||
|
// Called after the celebration is dismissed. Idempotent: opening an already
|
||||||
|
// opened gift changes nothing.
|
||||||
|
func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.OpenGift(r.Context(),
|
||||||
|
middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not open that gift")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiftableRewards — GET /admin/reward-gifts/catalog
|
||||||
|
func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.GiftableRewards(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load the catalog")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCustomGift — DELETE /admin/reward-gifts/{key}
|
||||||
|
func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrRewardNotFound) {
|
||||||
|
response.Error(w, http.StatusNotFound, "no such custom gift")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type customGiftReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Meaning string `json:"meaning"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomGift — POST /admin/reward-gifts/catalog
|
||||||
|
//
|
||||||
|
// Adds a sticker to the catalog. It reaches nobody until it is sent.
|
||||||
|
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req customGiftReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, reward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGiftImage — POST /admin/reward-gifts/image-presign
|
||||||
|
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{
|
||||||
|
"upload_url": upload, "image_url": public,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftReq struct {
|
||||||
|
RewardKey string `json:"reward_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiftToEveryone — POST /admin/reward-gifts
|
||||||
|
//
|
||||||
|
// Hands one reward to every account, unopened. Idempotent, so a repeated
|
||||||
|
// click tops up only the people who don't have it rather than duplicating.
|
||||||
|
func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req giftReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "reward_key is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrRewardNotFound) {
|
||||||
|
response.Error(w, http.StatusNotFound, "no such reward")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not send the gift")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]int64{"granted": n})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
|
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := middleware.UserID(r.Context())
|
uid := middleware.UserID(r.Context())
|
||||||
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
|
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ const searchStopwordsSet = "common"
|
|||||||
// searchResultItem represents a single search result in the API response.
|
// searchResultItem represents a single search result in the API response.
|
||||||
type searchResultItem struct {
|
type searchResultItem struct {
|
||||||
Collection string `json:"collection"`
|
Collection string `json:"collection"`
|
||||||
|
// DocID is the underlying Mongo _id hex — the indexer uses it as the
|
||||||
|
// Typesense document id. Surfaced so the admin panel can pin a specific
|
||||||
|
// item as the Vāṇī of the Day; it identifies public content, not anything
|
||||||
|
// private.
|
||||||
|
DocID string `json:"doc_id,omitempty"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Snippet string `json:"snippet"`
|
Snippet string `json:"snippet"`
|
||||||
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
|
|||||||
|
|
||||||
item := searchResultItem{
|
item := searchResultItem{
|
||||||
Collection: collection,
|
Collection: collection,
|
||||||
|
DocID: getStringField(doc, "id"),
|
||||||
URL: getStringField(doc, "url"),
|
URL: getStringField(doc, "url"),
|
||||||
score: score,
|
score: score,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
|
||||||
|
type StaffHandler struct {
|
||||||
|
svc *service.StaffService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
|
||||||
|
|
||||||
|
func writeStaffError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
|
||||||
|
errors.Is(err, repository.ErrUserGone):
|
||||||
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
|
||||||
|
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
|
||||||
|
errors.Is(err, repository.ErrRoleKeyTaken):
|
||||||
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
|
||||||
|
errors.Is(err, service.ErrRoleNoPermissions):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "staff request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me — GET /admin/me. The caller's role and effective permissions, which the
|
||||||
|
// panel uses to hide what they can't do. Hiding is a courtesy: every route
|
||||||
|
// enforces its own permission regardless.
|
||||||
|
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := middleware.Staff(r.Context())
|
||||||
|
response.JSON(w, http.StatusOK, service.Me{
|
||||||
|
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog — GET /admin/permissions. What a custom role may be built from.
|
||||||
|
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
response.JSON(w, http.StatusOK, rbac.Catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := h.svc.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grantReq struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant — POST /admin/staff
|
||||||
|
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req grantReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeRole — PATCH /admin/staff/{id}
|
||||||
|
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "role_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke — DELETE /admin/staff/{id}
|
||||||
|
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
|
||||||
|
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
|
||||||
|
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
|
||||||
|
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
|
||||||
|
|
||||||
|
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
roles, err := h.svc.Roles(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminRoleReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req adminRoleReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, ro)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req adminRoleReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, ro)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeStaffError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -18,6 +25,125 @@ func NewVaniHandler(svc *service.VaniService) *VaniHandler {
|
|||||||
|
|
||||||
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
||||||
// the current UTC day. Responds with `data: null` if there's no content.
|
// the current UTC day. Responds with `data: null` if there's no content.
|
||||||
|
type setVaniReq struct {
|
||||||
|
// Day as YYYY-MM-DD (UTC). Empty means today.
|
||||||
|
Day string `json:"day"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// RefID is the content's Mongo _id hex, which search returns as doc_id.
|
||||||
|
RefID string `json:"ref_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
|
||||||
|
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req setVaniReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
day, err := parseVaniDay(req.Day)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
|
||||||
|
if err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"day": pick.Day.Format("2006-01-02"),
|
||||||
|
"kind": pick.Kind,
|
||||||
|
"ref_id": pick.RefID,
|
||||||
|
"payload": pick.Payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type setVaniByURLReq struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
|
||||||
|
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req setVaniByURLReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
day, err := parseVaniDay(req.Day)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
|
||||||
|
if err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"day": pick.Day.Format("2006-01-02"),
|
||||||
|
"kind": pick.Kind,
|
||||||
|
"ref_id": pick.RefID,
|
||||||
|
"payload": pick.Payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
|
||||||
|
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
|
||||||
|
day, err := parseVaniDay(chi.URLParam(r, "day"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.ClearForDay(r.Context(), day); err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule — GET /admin/vani. Decided days from today onward.
|
||||||
|
func (h *VaniHandler) Schedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
picks, err := h.svc.Schedule(r.Context(), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(picks))
|
||||||
|
for _, p := range picks {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"day": p.Day.Format("2006-01-02"),
|
||||||
|
"kind": p.Kind,
|
||||||
|
"ref_id": p.RefID,
|
||||||
|
"payload": p.Payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVaniDay accepts YYYY-MM-DD, or empty for today (UTC — the pick is
|
||||||
|
// keyed on a UTC calendar day so everyone sees the same item).
|
||||||
|
func parseVaniDay(s string) (time.Time, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return time.Now().UTC().Truncate(24 * time.Hour), nil
|
||||||
|
}
|
||||||
|
return time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVaniError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrVaniRefInvalid),
|
||||||
|
errors.Is(err, service.ErrVaniBadKind),
|
||||||
|
errors.Is(err, service.ErrVaniBadURL):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, repository.ErrVaniRefNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "vāṇī request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
||||||
payload, err := h.svc.Today(r.Context())
|
payload, err := h.svc.Today(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
type staffCtxKey struct{}
|
||||||
// RequireAuth (it reads the authenticated user id from context, then looks up
|
|
||||||
// the row to check the role). Non-admins get 403; this keeps the whole
|
// StaffContext is the caller's resolved admin access, set by RequireStaff.
|
||||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
type StaffContext struct {
|
||||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
RoleKey string
|
||||||
|
RoleName string
|
||||||
|
Perms rbac.Set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff returns the caller's staff context, or nil outside RequireStaff.
|
||||||
|
func Staff(ctx context.Context) *StaffContext {
|
||||||
|
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireStaff admits accounts that hold an admin role and aren't blocked from
|
||||||
|
// the panel. It MUST run after RequireAuth.
|
||||||
|
//
|
||||||
|
// It resolves the role's permissions once per request and puts them in
|
||||||
|
// context, so the RequirePermission checks on each route are map lookups
|
||||||
|
// rather than further queries. Being staff gets you through the door only;
|
||||||
|
// every route then names the permission it needs.
|
||||||
|
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := UserID(r.Context())
|
uid := UserID(r.Context())
|
||||||
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
|||||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, err := users.FindByID(r.Context(), uid)
|
access, err := staff.Access(r.Context(), uid)
|
||||||
|
if errors.Is(err, repository.ErrNotStaff) {
|
||||||
|
response.Error(w, http.StatusForbidden, "admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.Role != "admin" {
|
if access.Blocked {
|
||||||
response.Error(w, http.StatusForbidden, "admin access required")
|
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
|
||||||
|
RoleKey: access.RoleKey,
|
||||||
|
RoleName: access.RoleName,
|
||||||
|
Perms: rbac.NewSet(access.Permissions),
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePermission gates a route to staff holding perm. Must run inside
|
||||||
|
// RequireStaff; outside it there is no staff context and everything is refused.
|
||||||
|
func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := Staff(r.Context())
|
||||||
|
if s == nil || !s.Perms.Has(perm) {
|
||||||
|
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withStaff(perms ...string) context.Context {
|
||||||
|
return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{
|
||||||
|
RoleKey: "x", Perms: rbac.NewSet(perms),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequirePermission(t *testing.T) {
|
||||||
|
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||||
|
gate := RequirePermission("users:ban")(ok)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden},
|
||||||
|
{"admin with it", withStaff("users:ban"), http.StatusNoContent},
|
||||||
|
{"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent},
|
||||||
|
// Outside RequireStaff there's no staff context at all: refuse.
|
||||||
|
{"no staff context", context.Background(), http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx))
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff management is never reachable through a catalog permission.
|
||||||
|
if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) {
|
||||||
|
t.Error("a non-wildcard set holds admins:manage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) }
|
||||||
@@ -17,6 +17,8 @@ type AdminUser struct {
|
|||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
BannedAt *time.Time `json:"banned_at"`
|
BannedAt *time.Time `json:"banned_at"`
|
||||||
|
// AdminRole is the staff role key, nil for ordinary accounts.
|
||||||
|
AdminRole *string `json:"admin_role,omitempty"`
|
||||||
PostCount int `json:"post_count"`
|
PostCount int `json:"post_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -171,11 +171,16 @@ const (
|
|||||||
// app-relative deep link back to the phrase, title the source reference,
|
// app-relative deep link back to the phrase, title the source reference,
|
||||||
// description the passage itself.
|
// description the passage itself.
|
||||||
AttachmentQuote = "quote"
|
AttachmentQuote = "quote"
|
||||||
|
// AttachmentSticker is reward artwork: image_url and title carry the art
|
||||||
|
// and its name, entity_id the reward key a recipient can save by.
|
||||||
|
AttachmentSticker = "sticker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entity types a message can reference.
|
// Entity types a message can reference.
|
||||||
const (
|
const (
|
||||||
EntityPost = "post"
|
EntityPost = "post"
|
||||||
|
// EntitySticker marks a sticker attachment's entity_id as a reward key.
|
||||||
|
EntitySticker = "sticker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment is a media file, link preview or shared entity on a message.
|
// Attachment is a media file, link preview or shared entity on a message.
|
||||||
@@ -252,6 +257,39 @@ type Message struct {
|
|||||||
// Attachments is always present (possibly empty) so clients need no nil
|
// Attachments is always present (possibly empty) so clients need no nil
|
||||||
// check before iterating.
|
// check before iterating.
|
||||||
Attachments []Attachment `json:"attachments"`
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
// Reactions is always present (possibly empty), same contract as
|
||||||
|
// Attachments — clients render the chip row without a nil check.
|
||||||
|
Reactions []Reaction `json:"reactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticker is one piece of reward artwork, as it appears in the picker and on
|
||||||
|
// a message.
|
||||||
|
type Sticker struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
// Saved is true when it came from a chat rather than from sādhana, so the
|
||||||
|
// picker can say where it came from.
|
||||||
|
Saved bool `json:"saved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionDetail is one person's reaction, for the "who reacted" list.
|
||||||
|
type ReactionDetail struct {
|
||||||
|
User ChatPeer `json:"user"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction is one emoji grouped across the people who chose it.
|
||||||
|
//
|
||||||
|
// Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the
|
||||||
|
// full membership of every reaction would grow with the room.
|
||||||
|
type Reaction struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
// Mine marks the viewer's own choice, so the chip can be highlighted and
|
||||||
|
// tapping it toggles off.
|
||||||
|
Mine bool `json:"mine"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receipts are the conversation-wide delivery cursors used to draw ticks on
|
// Receipts are the conversation-wide delivery cursors used to draw ticks on
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Quiz target kinds.
|
||||||
|
const (
|
||||||
|
QuizChapter = "chapter"
|
||||||
|
QuizLecture = "lecture"
|
||||||
|
QuizConversation = "conversation"
|
||||||
|
QuizStandalone = "standalone"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attempt states, as seen by the person taking the quiz.
|
||||||
|
const (
|
||||||
|
QuizNotStarted = "not_started"
|
||||||
|
QuizInProgress = "in_progress"
|
||||||
|
QuizCompleted = "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quiz is a set of multiple-choice questions, optionally tied to a page.
|
||||||
|
type Quiz struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TargetKind string `json:"target_kind"`
|
||||||
|
TargetPath *string `json:"target_path,omitempty"`
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
QuestionCount int `json:"question_count"`
|
||||||
|
// Admin-only counters.
|
||||||
|
AttemptCount int `json:"attempt_count,omitempty"`
|
||||||
|
CompletedCount int `json:"completed_count,omitempty"`
|
||||||
|
|
||||||
|
Questions []QuizQuestion `json:"questions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizQuestion is one question. Prompt and option bodies carry the same
|
||||||
|
// formatting markers as the rest of the admin text.
|
||||||
|
type QuizQuestion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ord int `json:"ord"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Explanation string `json:"explanation,omitempty"`
|
||||||
|
// Multi questions ask for every correct option ("select all that apply").
|
||||||
|
Multi bool `json:"multi"`
|
||||||
|
Options []QuizOption `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizOption is one choice. IsCorrect is nil whenever the reader must not
|
||||||
|
// know it yet — every response to someone mid-attempt.
|
||||||
|
type QuizOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ord int `json:"ord"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
IsCorrect *bool `json:"is_correct,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizStatus is the caller's progress on a quiz.
|
||||||
|
type QuizStatus struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Answered int `json:"answered"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Score *int `json:"score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizCard is a quiz plus the caller's progress, for buttons and lists.
|
||||||
|
type QuizCard struct {
|
||||||
|
Quiz
|
||||||
|
My QuizStatus `json:"my"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizPlay is the next step of an attempt: the current question, or Done.
|
||||||
|
type QuizPlay struct {
|
||||||
|
QuizID string `json:"quiz_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Answered int `json:"answered"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Question *QuizQuestion `json:"question,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizResultItem is one question on a results screen.
|
||||||
|
type QuizResultItem struct {
|
||||||
|
QuizQuestion
|
||||||
|
ChosenOptionIDs []string `json:"chosen_option_ids"`
|
||||||
|
IsCorrect bool `json:"is_correct"`
|
||||||
|
ResponseMs *int `json:"response_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizResult is a finished attempt.
|
||||||
|
type QuizResult struct {
|
||||||
|
Quiz Quiz `json:"quiz"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
TotalMs int `json:"total_ms"`
|
||||||
|
Items []QuizResultItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizParticipant is one person's attempt, for the admin participants list.
|
||||||
|
type QuizParticipant struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
Answered int `json:"answered"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalMs int `json:"total_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizOptionStat is how often one option was chosen.
|
||||||
|
type QuizOptionStat struct {
|
||||||
|
QuizOption
|
||||||
|
Picks int `json:"picks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizQuestionStat aggregates every response to one question.
|
||||||
|
type QuizQuestionStat struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ord int `json:"ord"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Multi bool `json:"multi"`
|
||||||
|
Answered int `json:"answered"`
|
||||||
|
Correct int `json:"correct"`
|
||||||
|
AvgMs int `json:"avg_ms"`
|
||||||
|
MedianMs int `json:"median_ms"`
|
||||||
|
Options []QuizOptionStat `json:"options"`
|
||||||
|
}
|
||||||
@@ -52,9 +52,16 @@ type SadhnaReward struct {
|
|||||||
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
|
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
|
||||||
// multiple of 7 days). The full reward is embedded for direct rendering.
|
// multiple of 7 days). The full reward is embedded for direct rendering.
|
||||||
type SadhnaRewardGrant struct {
|
type SadhnaRewardGrant struct {
|
||||||
Milestone int `json:"milestone"`
|
// Milestone is the streak day this was earned on. Nil for an admin gift,
|
||||||
|
// which isn't tied to a streak at all.
|
||||||
|
Milestone *int `json:"milestone"`
|
||||||
GrantedAt time.Time `json:"granted_at"`
|
GrantedAt time.Time `json:"granted_at"`
|
||||||
Reward SadhnaReward `json:"reward"`
|
Reward SadhnaReward `json:"reward"`
|
||||||
|
// Source is "streak" or "gift".
|
||||||
|
Source string `json:"source"`
|
||||||
|
// Opened is false while a gift is still wrapped — the gallery renders
|
||||||
|
// those blurred until the devotee opens them.
|
||||||
|
Opened bool `json:"opened"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It
|
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AdminRole is a named set of admin-panel permissions.
|
||||||
|
type AdminRole struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsSystem bool `json:"is_system"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
// MemberCount is how many staff hold the role, for the roles screen.
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaffMember is one account with admin-panel access.
|
||||||
|
type StaffMember struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
|
RoleKey string `json:"role_key"`
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaffAccess is what the admin middleware needs about the caller.
|
||||||
|
type StaffAccess struct {
|
||||||
|
RoleKey string
|
||||||
|
RoleName string
|
||||||
|
Permissions []string
|
||||||
|
Blocked bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Package rbac is the admin panel's permission catalog.
|
||||||
|
//
|
||||||
|
// Permissions are "resource:action" strings. The catalog lives in code, not in
|
||||||
|
// the database, so a permission can't exist without a route enforcing it, and
|
||||||
|
// adding one is a reviewed change. Roles (in admin_roles) are just named sets
|
||||||
|
// of these strings.
|
||||||
|
package rbac
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// Wildcard grants everything. Only the seeded superadmin role carries it.
|
||||||
|
const Wildcard = "*"
|
||||||
|
|
||||||
|
// ManageStaff is held by superadmins alone: granting, changing, revoking and
|
||||||
|
// blocking other staff. It is deliberately absent from Catalog, so no custom
|
||||||
|
// role can ever be given it.
|
||||||
|
const ManageStaff = "admins:manage"
|
||||||
|
|
||||||
|
// Permission is one grantable capability.
|
||||||
|
type Permission struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog is every permission a custom role may hold, grouped for the editor.
|
||||||
|
var Catalog = []Permission{
|
||||||
|
{"stats:read", "Overview", "View dashboard stats"},
|
||||||
|
|
||||||
|
{"users:read", "Users", "View users"},
|
||||||
|
{"users:notify", "Users", "Send users a message"},
|
||||||
|
{"users:ban", "Users", "Ban and unban users"},
|
||||||
|
{"users:limits", "Users", "Change bookmark and collection limits"},
|
||||||
|
{"disciples:manage", "Users", "Link and unlink direct disciples"},
|
||||||
|
|
||||||
|
{"posts:moderate", "Moderation", "Hide, delete and restore posts"},
|
||||||
|
{"comments:moderate", "Moderation", "Delete and restore comments"},
|
||||||
|
{"collections:read", "Moderation", "View collections"},
|
||||||
|
{"reports:read", "Moderation", "View reports"},
|
||||||
|
{"reports:resolve", "Moderation", "Resolve reports"},
|
||||||
|
|
||||||
|
{"feedback:read", "Feedback", "View feedback"},
|
||||||
|
{"feedback:reply", "Feedback", "Reply to feedback"},
|
||||||
|
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
|
||||||
|
|
||||||
|
{"vani:read", "Vāṇī of the Day", "View the schedule"},
|
||||||
|
{"vani:write", "Vāṇī of the Day", "Pin and clear days"},
|
||||||
|
|
||||||
|
{"rewards:read", "Rewards", "View the reward catalog"},
|
||||||
|
{"rewards:write", "Rewards", "Add custom gifts"},
|
||||||
|
{"rewards:send", "Rewards", "Send gifts to everyone"},
|
||||||
|
{"rewards:delete", "Rewards", "Delete custom gifts"},
|
||||||
|
|
||||||
|
{"broadcasts:read", "Announcements", "View announcements"},
|
||||||
|
{"broadcasts:write", "Announcements", "Create and delete drafts"},
|
||||||
|
{"broadcasts:publish", "Announcements", "Publish to everyone"},
|
||||||
|
|
||||||
|
{"quizzes:read", "Quizzes", "View quizzes"},
|
||||||
|
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
||||||
|
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
||||||
|
{"quizzes:results", "Quizzes", "View participants and responses"},
|
||||||
|
|
||||||
|
{"short_urls:read", "Short URLs", "View short links and stats"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether p is a grantable permission.
|
||||||
|
func Valid(p string) bool {
|
||||||
|
return slices.ContainsFunc(Catalog, func(c Permission) bool { return c.Key == p })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set is a resolved permission set for one staff member.
|
||||||
|
type Set struct {
|
||||||
|
all bool
|
||||||
|
keys map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSet resolves a role's stored permissions.
|
||||||
|
func NewSet(perms []string) Set {
|
||||||
|
s := Set{keys: map[string]bool{}}
|
||||||
|
for _, p := range perms {
|
||||||
|
if p == Wildcard {
|
||||||
|
s.all = true
|
||||||
|
}
|
||||||
|
s.keys[p] = true
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether the set grants p.
|
||||||
|
func (s Set) Has(p string) bool { return s.all || s.keys[p] }
|
||||||
|
|
||||||
|
// List is the effective permissions, for the admin UI to decide what to show.
|
||||||
|
// A wildcard expands to the full catalog plus ManageStaff.
|
||||||
|
func (s Set) List() []string {
|
||||||
|
if s.all {
|
||||||
|
out := make([]string, 0, len(Catalog)+1)
|
||||||
|
for _, c := range Catalog {
|
||||||
|
out = append(out, c.Key)
|
||||||
|
}
|
||||||
|
return append(out, ManageStaff)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(s.keys))
|
||||||
|
for k := range s.keys {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
|||||||
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||||
u.disciple_id,
|
u.disciple_id,
|
||||||
|
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||||
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
|||||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||||
&u.HasPassword, &u.Providers,
|
&u.HasPassword, &u.Providers,
|
||||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
||||||
&u.DiscipleID, &total); err != nil {
|
&u.DiscipleID, &u.AdminRole, &total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||||
}
|
}
|
||||||
// Resolve plan default + overrides so the panel shows what's in force.
|
// Resolve plan default + overrides so the panel shows what's in force.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ var (
|
|||||||
ErrAttachmentGone = errors.New("attachment not found")
|
ErrAttachmentGone = errors.New("attachment not found")
|
||||||
ErrMessageNotHere = errors.New("message is not in this conversation")
|
ErrMessageNotHere = errors.New("message is not in this conversation")
|
||||||
ErrUserGone = errors.New("user not found")
|
ErrUserGone = errors.New("user not found")
|
||||||
|
// ErrStickerNotOwned covers both "no such sticker" and "not yours" on
|
||||||
|
// purpose: the catalog shouldn't be enumerable by probing keys.
|
||||||
|
ErrStickerNotOwned = errors.New("sticker not available")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChatRepo struct{ pg *Postgres }
|
type ChatRepo struct{ pg *Postgres }
|
||||||
@@ -336,8 +339,10 @@ func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, lim
|
|||||||
// Messages returns one page of history, newest first. beforeSeq == 0 starts at
|
// Messages returns one page of history, newest first. beforeSeq == 0 starts at
|
||||||
// the newest message; otherwise it is an exclusive upper bound, which makes
|
// the newest message; otherwise it is an exclusive upper bound, which makes
|
||||||
// paging stable even as new messages arrive at the top.
|
// paging stable even as new messages arrive at the top.
|
||||||
func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) {
|
// messageSelectFrom is the canonical read shape for a message, including the
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
// quoted-reply preview. Shared by the paged thread read and the single-message
|
||||||
|
// read so the two can never drift into returning different shapes.
|
||||||
|
const messageSelectFrom = `
|
||||||
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
|
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
|
||||||
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at,
|
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at,
|
||||||
rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '')
|
rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '')
|
||||||
@@ -351,12 +356,41 @@ func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string,
|
|||||||
-- thread's text through a quote.
|
-- thread's text through a quote.
|
||||||
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
|
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
|
||||||
) rep ON TRUE
|
) rep ON TRUE
|
||||||
|
`
|
||||||
|
|
||||||
|
// Message reads one message of a conversation, in the same shape as the paged
|
||||||
|
// read. Hidden (deleted-for-me) messages are excluded, so a refresh can't
|
||||||
|
// resurrect something the caller removed from their own view.
|
||||||
|
func (r *ChatRepo) Message(ctx context.Context, conversationID, messageID, userID string) (*model.Message, error) {
|
||||||
|
msgs, err := r.scanMessages(ctx, messageSelectFrom+`
|
||||||
|
WHERE m.conversation_id = $1
|
||||||
|
AND m.id = $2
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM message_hides h
|
||||||
|
WHERE h.message_id = m.id AND h.user_id = $3)`,
|
||||||
|
conversationID, messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil, ErrMessageNotHere
|
||||||
|
}
|
||||||
|
return &msgs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) {
|
||||||
|
return r.scanMessages(ctx, messageSelectFrom+`
|
||||||
WHERE m.conversation_id = $1
|
WHERE m.conversation_id = $1
|
||||||
AND ($2 = 0 OR m.seq < $2)
|
AND ($2 = 0 OR m.seq < $2)
|
||||||
AND NOT EXISTS (SELECT 1 FROM message_hides h
|
AND NOT EXISTS (SELECT 1 FROM message_hides h
|
||||||
WHERE h.message_id = m.id AND h.user_id = $3)
|
WHERE h.message_id = m.id AND h.user_id = $3)
|
||||||
ORDER BY m.seq DESC
|
ORDER BY m.seq DESC
|
||||||
LIMIT $4`, conversationID, beforeSeq, userID, limit)
|
LIMIT $4`, conversationID, beforeSeq, userID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMessages runs a query built on messageSelectFrom and decodes its rows.
|
||||||
|
// Shared so the paged read and the single-message read decode identically.
|
||||||
|
func (r *ChatRepo) scanMessages(ctx context.Context, query string, args ...any) ([]model.Message, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query messages: %w", err)
|
return nil, fmt.Errorf("query messages: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1157,6 +1191,237 @@ func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReaction records one person's emoji on a message, replacing whatever
|
||||||
|
// they chose before. Returns the message's conversation so the caller can
|
||||||
|
// authorize and publish without a second query.
|
||||||
|
func (r *ChatRepo) SetReaction(ctx context.Context, messageID, userID, emoji string) (conversationID string, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO message_reactions (message_id, user_id, conversation_id, emoji)
|
||||||
|
SELECT $1, $2, m.conversation_id, $3
|
||||||
|
FROM messages m
|
||||||
|
WHERE m.id = $1 AND m.deleted_at IS NULL
|
||||||
|
ON CONFLICT (message_id, user_id)
|
||||||
|
DO UPDATE SET emoji = EXCLUDED.emoji, updated_at = now()
|
||||||
|
RETURNING conversation_id`, messageID, userID, emoji).Scan(&conversationID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// No row inserted means no such message, or it was deleted — you
|
||||||
|
// cannot react to something that isn't there.
|
||||||
|
return "", ErrMessageNotHere
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("set reaction: %w", err)
|
||||||
|
}
|
||||||
|
return conversationID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearReaction removes this user's reaction. Returns the conversation so the
|
||||||
|
// caller can publish the change; ErrMessageNotHere when there was none.
|
||||||
|
func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string) (conversationID string, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
DELETE FROM message_reactions
|
||||||
|
WHERE message_id = $1 AND user_id = $2
|
||||||
|
RETURNING conversation_id`, messageID, userID).Scan(&conversationID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrMessageNotHere
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("clear reaction: %w", err)
|
||||||
|
}
|
||||||
|
return conversationID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stickers lists everything a user may send: rewards they earned through
|
||||||
|
// sādhana, plus stickers they saved from a chat.
|
||||||
|
//
|
||||||
|
// One UNION rather than two calls and a merge in Go — "may I send this?" and
|
||||||
|
// "what can I send?" then have exactly one answer, and it stays correct if a
|
||||||
|
// user both earns and is sent the same sticker.
|
||||||
|
func (r *ChatRepo) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT sr.key, sr.name, sr.image_url, bool_and(src.saved) AS saved
|
||||||
|
FROM (
|
||||||
|
SELECT reward_id, FALSE AS saved FROM sadhna_reward_grants WHERE user_id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT reward_id, TRUE AS saved FROM saved_stickers WHERE user_id = $1
|
||||||
|
) src
|
||||||
|
JOIN sadhna_rewards sr ON sr.id = src.reward_id
|
||||||
|
WHERE sr.active
|
||||||
|
GROUP BY sr.key, sr.name, sr.image_url, sr.sort
|
||||||
|
ORDER BY sr.sort, sr.name`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stickers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.Sticker{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s model.Sticker
|
||||||
|
if err := rows.Scan(&s.Key, &s.Name, &s.ImageURL, &s.Saved); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan sticker: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticker looks up one piece of artwork a user is entitled to send.
|
||||||
|
// ErrStickerNotOwned when they have neither earned nor saved it.
|
||||||
|
func (r *ChatRepo) Sticker(ctx context.Context, userID, key string) (*model.Sticker, error) {
|
||||||
|
var s model.Sticker
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT sr.key, sr.name, sr.image_url
|
||||||
|
FROM sadhna_rewards sr
|
||||||
|
WHERE sr.key = $2 AND sr.active
|
||||||
|
AND (EXISTS (SELECT 1 FROM sadhna_reward_grants g
|
||||||
|
WHERE g.user_id = $1 AND g.reward_id = sr.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM saved_stickers ss
|
||||||
|
WHERE ss.user_id = $1 AND ss.reward_id = sr.id))`,
|
||||||
|
userID, key).Scan(&s.Key, &s.Name, &s.ImageURL)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrStickerNotOwned
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sticker: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSticker keeps a sticker someone sent. Idempotent.
|
||||||
|
//
|
||||||
|
// Only stickers that actually appear on a message in a thread the caller is
|
||||||
|
// in can be saved — otherwise knowing a key would be enough to collect the
|
||||||
|
// whole catalog without earning any of it.
|
||||||
|
func (r *ChatRepo) SaveSticker(ctx context.Context, userID, key, fromMessageID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO saved_stickers (user_id, reward_id, saved_from)
|
||||||
|
SELECT $1, sr.id, m.sender_id
|
||||||
|
FROM message_attachments a
|
||||||
|
JOIN messages m ON m.id = a.message_id
|
||||||
|
JOIN conversation_participants p
|
||||||
|
ON p.conversation_id = m.conversation_id AND p.user_id = $1 AND p.left_at IS NULL
|
||||||
|
JOIN sadhna_rewards sr ON sr.key = a.entity_id AND sr.active
|
||||||
|
WHERE a.message_id = $3 AND a.kind = 'sticker' AND a.entity_id = $2
|
||||||
|
ON CONFLICT (user_id, reward_id) DO NOTHING`,
|
||||||
|
userID, key, fromMessageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save sticker: %w", err)
|
||||||
|
}
|
||||||
|
// Zero rows means the sticker isn't on that message, or the caller isn't
|
||||||
|
// in the thread. Both are "you can't have this", not "already saved" —
|
||||||
|
// a repeat save conflicts instead, which also reports zero.
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
var exists bool
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM saved_stickers ss
|
||||||
|
JOIN sadhna_rewards sr ON sr.id = ss.reward_id
|
||||||
|
WHERE ss.user_id = $1 AND sr.key = $2)`,
|
||||||
|
userID, key).Scan(&exists); err != nil {
|
||||||
|
return fmt.Errorf("save sticker check: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return ErrStickerNotOwned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyAttachments duplicates one message's attachments onto another.
|
||||||
|
//
|
||||||
|
// Forwarding needs this: a quote, a sticker, a photo or a shared post lives in
|
||||||
|
// an attachment row, not in the body, so copying only body+entities forwards
|
||||||
|
// an empty bubble.
|
||||||
|
//
|
||||||
|
// conversation_id is rewritten to the destination, which is what keeps access
|
||||||
|
// correct: reads authorize against the attachment's OWN conversation, so the
|
||||||
|
// copy is readable by the new thread's participants and the original stays
|
||||||
|
// scoped to the old one. Media rows point at the same stored object rather
|
||||||
|
// than duplicating it.
|
||||||
|
//
|
||||||
|
// Entity rows carry only their reference forward; visibility is re-resolved
|
||||||
|
// per viewer on every read, so forwarding a post cannot leak one.
|
||||||
|
func (r *ChatRepo) CopyAttachments(ctx context.Context, fromMessageID, toMessageID, toConversationID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO message_attachments
|
||||||
|
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
|
||||||
|
width, height, duration_ms, blurhash, file_name,
|
||||||
|
url, title, description, site_name, image_url,
|
||||||
|
entity_type, entity_id, snapshot)
|
||||||
|
SELECT $2, $3, ord, kind, r2_key, mime, size_bytes,
|
||||||
|
width, height, duration_ms, blurhash, file_name,
|
||||||
|
url, title, description, site_name, image_url,
|
||||||
|
entity_type, entity_id, snapshot
|
||||||
|
FROM message_attachments
|
||||||
|
WHERE message_id = $1
|
||||||
|
ORDER BY ord`, fromMessageID, toMessageID, toConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy attachments: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionDetails lists everyone who reacted to one message, newest first.
|
||||||
|
//
|
||||||
|
// Separate from ReactionsFor: the grouped counts are what a thread renders,
|
||||||
|
// and sending the full membership of every reaction on every page would grow
|
||||||
|
// with the room. This is only read when someone opens the list.
|
||||||
|
func (r *ChatRepo) ReactionDetails(ctx context.Context, messageID string) ([]model.ReactionDetail, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT u.id, u.handle, u.name, u.avatar_url, mr.emoji, mr.updated_at
|
||||||
|
FROM message_reactions mr
|
||||||
|
JOIN users u ON u.id = mr.user_id
|
||||||
|
WHERE mr.message_id = $1
|
||||||
|
ORDER BY mr.updated_at DESC`, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reaction details: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.ReactionDetail{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d model.ReactionDetail
|
||||||
|
if err := rows.Scan(&d.User.ID, &d.User.Handle, &d.User.Name,
|
||||||
|
&d.User.AvatarURL, &d.Emoji, &d.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan reaction detail: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionsFor groups reactions for a page of messages in one query.
|
||||||
|
//
|
||||||
|
// Grouped in SQL rather than in Go: the counts are what the UI draws, and
|
||||||
|
// sending one row per person would grow with the room.
|
||||||
|
func (r *ChatRepo) ReactionsFor(ctx context.Context, messageIDs []string, viewerID string) (map[string][]model.Reaction, error) {
|
||||||
|
out := map[string][]model.Reaction{}
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT message_id, emoji, count(*),
|
||||||
|
bool_or(user_id = $2) AS mine
|
||||||
|
FROM message_reactions
|
||||||
|
WHERE message_id = ANY($1)
|
||||||
|
GROUP BY message_id, emoji
|
||||||
|
-- Most-used first, then stable by emoji so the chip order doesn't
|
||||||
|
-- shuffle between reloads.
|
||||||
|
ORDER BY message_id, count(*) DESC, emoji`, messageIDs, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reactions for: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var messageID string
|
||||||
|
var re model.Reaction
|
||||||
|
if err := rows.Scan(&messageID, &re.Emoji, &re.Count, &re.Mine); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan reaction: %w", err)
|
||||||
|
}
|
||||||
|
out[messageID] = append(out[messageID], re)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// PushCandidate is a participant plus the state that decides whether a new
|
// PushCandidate is a participant plus the state that decides whether a new
|
||||||
// message should reach them out-of-band.
|
// message should reach them out-of-band.
|
||||||
type PushCandidate struct {
|
type PushCandidate struct {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
|
|||||||
END AS rank
|
END AS rank
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id <> $1
|
WHERE u.id <> $1
|
||||||
|
-- A suspended account can't be @mentioned into a post.
|
||||||
|
AND u.banned_at IS NULL
|
||||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||||
ORDER BY rank, u.name, u.id
|
ORDER BY rank, u.name, u.id
|
||||||
LIMIT $4 OFFSET $5`,
|
LIMIT $4 OFFSET $5`,
|
||||||
|
|||||||
@@ -0,0 +1,693 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrQuizNotFound = errors.New("quiz not found")
|
||||||
|
ErrQuizNotStarted = errors.New("start the quiz first")
|
||||||
|
ErrQuizNotCurrent = errors.New("that isn't the current question")
|
||||||
|
ErrQuizBadOption = errors.New("that option isn't part of this question")
|
||||||
|
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
|
||||||
|
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
|
||||||
|
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuizRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewQuizRepo(pg *Postgres) *QuizRepo { return &QuizRepo{pg: pg} }
|
||||||
|
|
||||||
|
// QuizInput is a whole quiz as the admin editor submits it.
|
||||||
|
type QuizInput struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
TargetKind string
|
||||||
|
TargetPath *string
|
||||||
|
TargetTitle string
|
||||||
|
Questions []QuestionInput
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionInput struct {
|
||||||
|
Prompt string
|
||||||
|
Explanation string
|
||||||
|
Multi bool
|
||||||
|
Options []OptionInput
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptionInput struct {
|
||||||
|
Body string
|
||||||
|
IsCorrect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
|
||||||
|
q.target_title, q.published_at, q.created_at, q.updated_at,
|
||||||
|
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
|
||||||
|
|
||||||
|
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
|
||||||
|
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
|
||||||
|
&q.TargetTitle, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
|
||||||
|
return row.Scan(append(dest, extra...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (r *QuizRepo) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`,
|
||||||
|
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id),
|
||||||
|
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id AND a.completed_at IS NOT NULL)
|
||||||
|
FROM quizzes q WHERE q.deleted_at IS NULL
|
||||||
|
ORDER BY q.updated_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list quizzes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Quiz{}
|
||||||
|
for rows.Next() {
|
||||||
|
var q model.Quiz
|
||||||
|
if err := scanQuiz(rows, &q, &q.AttemptCount, &q.CompletedCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan quiz: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get loads a quiz with its questions. revealAnswers controls whether
|
||||||
|
// IsCorrect is filled in: true for the admin editor and results screens,
|
||||||
|
// false for anyone mid-attempt.
|
||||||
|
func (r *QuizRepo) Get(ctx context.Context, id string, revealAnswers bool) (*model.Quiz, error) {
|
||||||
|
var q model.Quiz
|
||||||
|
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
|
||||||
|
FROM quizzes q WHERE q.id = $1 AND q.deleted_at IS NULL`, id), &q)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrQuizNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load quiz: %w", err)
|
||||||
|
}
|
||||||
|
if q.Questions, err = r.questions(ctx, id, revealAnswers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuizRepo) questions(ctx context.Context, quizID string, reveal bool) ([]model.QuizQuestion, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT qq.id, qq.ord, qq.prompt, qq.explanation, qq.multi,
|
||||||
|
o.id, o.ord, o.body, o.is_correct
|
||||||
|
FROM quiz_questions qq
|
||||||
|
JOIN quiz_options o ON o.question_id = qq.id
|
||||||
|
WHERE qq.quiz_id = $1
|
||||||
|
ORDER BY qq.ord, o.ord`, quizID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load questions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.QuizQuestion{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
qid, prompt, expl, oid, body string
|
||||||
|
qord, oord int
|
||||||
|
multi, correct bool
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&qid, &qord, &prompt, &expl, &multi, &oid, &oord, &body, &correct); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan question: %w", err)
|
||||||
|
}
|
||||||
|
if len(out) == 0 || out[len(out)-1].ID != qid {
|
||||||
|
out = append(out, model.QuizQuestion{ID: qid, Ord: qord, Prompt: prompt, Explanation: expl, Multi: multi})
|
||||||
|
}
|
||||||
|
opt := model.QuizOption{ID: oid, Ord: oord, Body: body}
|
||||||
|
if reveal {
|
||||||
|
c := correct
|
||||||
|
opt.IsCorrect = &c
|
||||||
|
}
|
||||||
|
last := &out[len(out)-1]
|
||||||
|
last.Options = append(last.Options, opt)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores a new quiz, unpublished, with its questions.
|
||||||
|
func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (string, error) {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("begin quiz tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
var id string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, createdBy,
|
||||||
|
).Scan(&id); err != nil {
|
||||||
|
return "", fmt.Errorf("create quiz: %w", err)
|
||||||
|
}
|
||||||
|
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rewrites a quiz. replaceQuestions is false once anyone has made an
|
||||||
|
// attempt: their answers point at specific questions and options, so those
|
||||||
|
// are frozen and only the title, description and target may change.
|
||||||
|
func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQuestions bool) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin quiz tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
|
||||||
|
target_path = $5, target_title = $6, updated_at = now()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update quiz: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrQuizNotFound
|
||||||
|
}
|
||||||
|
if replaceQuestions {
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM quiz_questions WHERE quiz_id = $1`, id); err != nil {
|
||||||
|
return fmt.Errorf("clear questions: %w", err)
|
||||||
|
}
|
||||||
|
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []QuestionInput) error {
|
||||||
|
for i, q := range qs {
|
||||||
|
var qid string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO quiz_questions (quiz_id, ord, prompt, explanation, multi)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`, quizID, i, q.Prompt, q.Explanation, q.Multi,
|
||||||
|
).Scan(&qid); err != nil {
|
||||||
|
return fmt.Errorf("insert question: %w", err)
|
||||||
|
}
|
||||||
|
for j, o := range q.Options {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO quiz_options (question_id, ord, body, is_correct)
|
||||||
|
VALUES ($1, $2, $3, $4)`, qid, j, o.Body, o.IsCorrect); err != nil {
|
||||||
|
return fmt.Errorf("insert option: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuizRepo) SetPublished(ctx context.Context, id string, published bool) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE quizzes
|
||||||
|
SET published_at = CASE WHEN $2 THEN COALESCE(published_at, now()) ELSE NULL END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`, id, published)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publish quiz: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrQuizNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete hides a quiz everywhere. Soft, so participants' results survive.
|
||||||
|
func (r *QuizRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`UPDATE quizzes SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete quiz: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrQuizNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participants lists everyone who started a quiz, best scores first.
|
||||||
|
func (r *QuizRepo) Participants(ctx context.Context, quizID string) ([]model.QuizParticipant, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT u.id, u.handle, u.name, u.avatar_url, a.started_at, a.completed_at,
|
||||||
|
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL),
|
||||||
|
a.score, a.total,
|
||||||
|
COALESCE((SELECT sum(x.response_ms) FROM quiz_answers x WHERE x.attempt_id = a.id), 0)
|
||||||
|
FROM quiz_attempts a
|
||||||
|
JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.quiz_id = $1
|
||||||
|
ORDER BY (a.completed_at IS NOT NULL) DESC, a.score DESC, a.started_at`, quizID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("quiz participants: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.QuizParticipant{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.QuizParticipant
|
||||||
|
if err := rows.Scan(&p.UserID, &p.Handle, &p.Name, &p.AvatarURL, &p.StartedAt,
|
||||||
|
&p.CompletedAt, &p.Answered, &p.Score, &p.Total, &p.TotalMs); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan participant: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestionStats aggregates every answer per question and option.
|
||||||
|
func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.QuizQuestionStat, error) {
|
||||||
|
qs, err := r.questions(ctx, quizID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT x.question_id, COALESCE(x.option_ids::text[], '{}'), x.is_correct, x.response_ms
|
||||||
|
FROM quiz_answers x
|
||||||
|
JOIN quiz_questions qq ON qq.id = x.question_id
|
||||||
|
WHERE qq.quiz_id = $1 AND x.answered_at IS NOT NULL`, quizID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("quiz answers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
picks := map[string]int{}
|
||||||
|
type agg struct {
|
||||||
|
answered, correct int
|
||||||
|
times []int
|
||||||
|
}
|
||||||
|
per := map[string]*agg{}
|
||||||
|
for rows.Next() {
|
||||||
|
var qid string
|
||||||
|
var oids []string
|
||||||
|
var correct *bool
|
||||||
|
var ms *int
|
||||||
|
if err := rows.Scan(&qid, &oids, &correct, &ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan answer: %w", err)
|
||||||
|
}
|
||||||
|
a := per[qid]
|
||||||
|
if a == nil {
|
||||||
|
a = &agg{}
|
||||||
|
per[qid] = a
|
||||||
|
}
|
||||||
|
a.answered++
|
||||||
|
if correct != nil && *correct {
|
||||||
|
a.correct++
|
||||||
|
}
|
||||||
|
if ms != nil {
|
||||||
|
a.times = append(a.times, *ms)
|
||||||
|
}
|
||||||
|
for _, oid := range oids {
|
||||||
|
picks[oid]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]model.QuizQuestionStat, 0, len(qs))
|
||||||
|
for _, q := range qs {
|
||||||
|
st := model.QuizQuestionStat{ID: q.ID, Ord: q.Ord, Prompt: q.Prompt, Multi: q.Multi}
|
||||||
|
if a := per[q.ID]; a != nil {
|
||||||
|
st.Answered, st.Correct = a.answered, a.correct
|
||||||
|
if n := len(a.times); n > 0 {
|
||||||
|
sum := 0
|
||||||
|
for _, t := range a.times {
|
||||||
|
sum += t
|
||||||
|
}
|
||||||
|
st.AvgMs = sum / n
|
||||||
|
sort.Ints(a.times)
|
||||||
|
st.MedianMs = a.times[n/2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range q.Options {
|
||||||
|
st.Options = append(st.Options, model.QuizOptionStat{QuizOption: o, Picks: picks[o.ID]})
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
||||||
|
func (r *QuizRepo) PublishedForPath(ctx context.Context, path string) (*model.Quiz, error) {
|
||||||
|
var q model.Quiz
|
||||||
|
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
|
||||||
|
FROM quizzes q
|
||||||
|
WHERE q.target_path = $1 AND q.deleted_at IS NULL AND q.published_at IS NOT NULL
|
||||||
|
ORDER BY q.published_at DESC LIMIT 1`, path), &q)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("quiz for path: %w", err)
|
||||||
|
}
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublished returns every live quiz, newest first.
|
||||||
|
func (r *QuizRepo) ListPublished(ctx context.Context) ([]model.Quiz, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`
|
||||||
|
FROM quizzes q
|
||||||
|
WHERE q.deleted_at IS NULL AND q.published_at IS NOT NULL
|
||||||
|
ORDER BY q.published_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list published quizzes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Quiz{}
|
||||||
|
for rows.Next() {
|
||||||
|
var q model.Quiz
|
||||||
|
if err := scanQuiz(rows, &q); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan quiz: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statuses returns the caller's progress on each of the given quizzes.
|
||||||
|
// Quizzes they haven't touched are simply absent.
|
||||||
|
func (r *QuizRepo) Statuses(ctx context.Context, userID string, quizIDs []string) (map[string]model.QuizStatus, error) {
|
||||||
|
out := map[string]model.QuizStatus{}
|
||||||
|
if userID == "" || len(quizIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT a.quiz_id, a.completed_at IS NOT NULL, a.score, a.total,
|
||||||
|
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL)
|
||||||
|
FROM quiz_attempts a
|
||||||
|
WHERE a.user_id = $1 AND a.quiz_id = ANY($2)`, userID, quizIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("quiz statuses: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
var done bool
|
||||||
|
var score, total, answered int
|
||||||
|
if err := rows.Scan(&id, &done, &score, &total, &answered); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan status: %w", err)
|
||||||
|
}
|
||||||
|
st := model.QuizStatus{State: model.QuizInProgress, Answered: answered, Total: total}
|
||||||
|
if done {
|
||||||
|
st.State = model.QuizCompleted
|
||||||
|
s := score
|
||||||
|
st.Score = &s
|
||||||
|
}
|
||||||
|
out[id] = st
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type attemptRow struct {
|
||||||
|
ID string
|
||||||
|
StartedAt time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
Score int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuizRepo) attempt(ctx context.Context, quizID, userID string) (*attemptRow, error) {
|
||||||
|
var a attemptRow
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, started_at, completed_at, score, total
|
||||||
|
FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID,
|
||||||
|
).Scan(&a.ID, &a.StartedAt, &a.CompletedAt, &a.Score, &a.Total)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrQuizNotStarted
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load attempt: %w", err)
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start opens the caller's attempt. Idempotent: starting again resumes.
|
||||||
|
func (r *QuizRepo) Start(ctx context.Context, quizID, userID string, total int) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO quiz_attempts (quiz_id, user_id, total) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (quiz_id, user_id) DO NOTHING`, quizID, userID, total)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start attempt: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the first unanswered question and stamps the moment it was
|
||||||
|
// served. The stamp is set ONCE: fetching the same question again — a reload,
|
||||||
|
// a second tab — doesn't restart its clock.
|
||||||
|
func (r *QuizRepo) Next(ctx context.Context, quizID, userID string) (*model.QuizPlay, error) {
|
||||||
|
a, err := r.attempt(ctx, quizID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
play := &model.QuizPlay{QuizID: quizID, Total: a.Total}
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND answered_at IS NOT NULL`,
|
||||||
|
a.ID).Scan(&play.Answered); err != nil {
|
||||||
|
return nil, fmt.Errorf("count answered: %w", err)
|
||||||
|
}
|
||||||
|
if a.CompletedAt != nil {
|
||||||
|
play.Done = true
|
||||||
|
return play, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var qid string
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT qq.id FROM quiz_questions qq
|
||||||
|
WHERE qq.quiz_id = $1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM quiz_answers x
|
||||||
|
WHERE x.attempt_id = $2 AND x.question_id = qq.id
|
||||||
|
AND x.answered_at IS NOT NULL)
|
||||||
|
ORDER BY qq.ord LIMIT 1`, quizID, a.ID).Scan(&qid)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Every question answered but not yet marked complete — finish it.
|
||||||
|
if err := r.complete(ctx, a.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
play.Done = true
|
||||||
|
return play, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("next question: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO quiz_answers (attempt_id, question_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING`, a.ID, qid); err != nil {
|
||||||
|
return nil, fmt.Errorf("stamp question: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qs, err := r.questions(ctx, quizID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range qs {
|
||||||
|
if qs[i].ID == qid {
|
||||||
|
play.Question = &qs[i]
|
||||||
|
// The explanation discusses the answer; it belongs on the
|
||||||
|
// results screen, not beside the question being answered.
|
||||||
|
play.Question.Explanation = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return play, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer records the caller's choice on the CURRENT question: one option, or
|
||||||
|
// for a multi question every option they think applies.
|
||||||
|
//
|
||||||
|
// The question must have been served (Next stamped it), which enforces order
|
||||||
|
// and gives the server its own start time. Response time is shown→answered,
|
||||||
|
// both server clocks. An answer can't be changed once given. A multi answer
|
||||||
|
// is correct only when the chosen set equals the correct set exactly.
|
||||||
|
func (r *QuizRepo) Answer(ctx context.Context, quizID, userID, questionID string, optionIDs []string) error {
|
||||||
|
a, err := r.attempt(ctx, quizID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.CompletedAt != nil {
|
||||||
|
return ErrQuizAlreadyAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
chosen := map[string]bool{}
|
||||||
|
for _, id := range optionIDs {
|
||||||
|
chosen[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin answer tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT qq.multi, o.id, o.is_correct FROM quiz_options o
|
||||||
|
JOIN quiz_questions qq ON qq.id = o.question_id
|
||||||
|
WHERE o.question_id = $1 AND qq.quiz_id = $2`, questionID, quizID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check options: %w", err)
|
||||||
|
}
|
||||||
|
var multi bool
|
||||||
|
known, wrongPick, missed := 0, false, false
|
||||||
|
for rows.Next() {
|
||||||
|
var oid string
|
||||||
|
var isCorrect bool
|
||||||
|
if err := rows.Scan(&multi, &oid, &isCorrect); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("scan option: %w", err)
|
||||||
|
}
|
||||||
|
if chosen[oid] {
|
||||||
|
known++
|
||||||
|
wrongPick = wrongPick || !isCorrect
|
||||||
|
} else if isCorrect {
|
||||||
|
missed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("check options: %w", err)
|
||||||
|
}
|
||||||
|
if known != len(chosen) {
|
||||||
|
// Something chosen isn't an option of this question (or the question
|
||||||
|
// isn't part of this quiz at all).
|
||||||
|
return ErrQuizBadOption
|
||||||
|
}
|
||||||
|
if len(chosen) == 0 || (!multi && len(chosen) != 1) {
|
||||||
|
return ErrQuizChoiceCount
|
||||||
|
}
|
||||||
|
correct := !wrongPick && !missed
|
||||||
|
ids := make([]string, 0, len(chosen))
|
||||||
|
for _, id := range optionIDs {
|
||||||
|
if chosen[id] {
|
||||||
|
ids = append(ids, id)
|
||||||
|
delete(chosen, id) // keep the first of any duplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var answeredAt *time.Time
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT answered_at FROM quiz_answers
|
||||||
|
WHERE attempt_id = $1 AND question_id = $2 FOR UPDATE`, a.ID, questionID).Scan(&answeredAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrQuizNotCurrent
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load answer row: %w", err)
|
||||||
|
}
|
||||||
|
if answeredAt != nil {
|
||||||
|
return ErrQuizAlreadyAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE quiz_answers
|
||||||
|
SET option_ids = $3::uuid[], answered_at = now(), is_correct = $4,
|
||||||
|
response_ms = GREATEST(0, (EXTRACT(EPOCH FROM (now() - shown_at)) * 1000)::int)
|
||||||
|
WHERE attempt_id = $1 AND question_id = $2`,
|
||||||
|
a.ID, questionID, ids, correct); err != nil {
|
||||||
|
return fmt.Errorf("record answer: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish the attempt once nothing is left unanswered.
|
||||||
|
var remaining int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM quiz_questions qq
|
||||||
|
WHERE qq.quiz_id = $1 AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM quiz_answers x WHERE x.attempt_id = $2
|
||||||
|
AND x.question_id = qq.id AND x.answered_at IS NOT NULL)`,
|
||||||
|
quizID, a.ID).Scan(&remaining); err != nil {
|
||||||
|
return fmt.Errorf("count remaining: %w", err)
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
return r.complete(ctx, a.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuizRepo) complete(ctx context.Context, attemptID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE quiz_attempts SET completed_at = now(),
|
||||||
|
score = (SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND is_correct)
|
||||||
|
WHERE id = $1 AND completed_at IS NULL`, attemptID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("complete attempt: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result returns an attempt with the answers revealed.
|
||||||
|
//
|
||||||
|
// allowIncomplete is false on the user's path, which refuses until the attempt
|
||||||
|
// is finished — otherwise a result would be a way to peek at the answers. The
|
||||||
|
// admin passes true to inspect an attempt still in progress.
|
||||||
|
func (r *QuizRepo) Result(ctx context.Context, quizID, userID string, allowIncomplete bool) (*model.QuizResult, error) {
|
||||||
|
a, err := r.attempt(ctx, quizID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.CompletedAt == nil && !allowIncomplete {
|
||||||
|
return nil, ErrQuizNotFinished
|
||||||
|
}
|
||||||
|
quiz, err := r.Get(ctx, quizID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res := &model.QuizResult{
|
||||||
|
Score: a.Score, Total: a.Total, StartedAt: a.StartedAt, CompletedAt: a.CompletedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT question_id, COALESCE(option_ids::text[], '{}'), COALESCE(is_correct, false), response_ms
|
||||||
|
FROM quiz_answers WHERE attempt_id = $1`, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load result answers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type ans struct {
|
||||||
|
options []string
|
||||||
|
correct bool
|
||||||
|
ms *int
|
||||||
|
}
|
||||||
|
got := map[string]ans{}
|
||||||
|
for rows.Next() {
|
||||||
|
var qid string
|
||||||
|
var x ans
|
||||||
|
if err := rows.Scan(&qid, &x.options, &x.correct, &x.ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan result answer: %w", err)
|
||||||
|
}
|
||||||
|
got[qid] = x
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range quiz.Questions {
|
||||||
|
item := model.QuizResultItem{QuizQuestion: q, ChosenOptionIDs: []string{}}
|
||||||
|
if x, ok := got[q.ID]; ok {
|
||||||
|
item.ChosenOptionIDs, item.IsCorrect, item.ResponseMs = x.options, x.correct, x.ms
|
||||||
|
if x.ms != nil {
|
||||||
|
res.TotalMs += *x.ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Items = append(res.Items, item)
|
||||||
|
}
|
||||||
|
quiz.Questions = nil
|
||||||
|
res.Quiz = *quiz
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrRewardNotFound is an unknown or retired reward key.
|
||||||
|
var ErrRewardNotFound = errors.New("reward not found")
|
||||||
|
|
||||||
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
|
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
|
||||||
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
|
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
|
||||||
// embedded so the BFF can seed/sync the catalog on startup with no external
|
// embedded so the BFF can seed/sync the catalog on startup with no external
|
||||||
@@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er
|
|||||||
// reward embedded for direct rendering.
|
// reward embedded for direct rendering.
|
||||||
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT g.milestone, g.granted_at,
|
SELECT g.milestone, g.granted_at, g.source, g.celebrated_at IS NOT NULL,
|
||||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||||
FROM sadhna_reward_grants g
|
FROM sadhna_reward_grants g
|
||||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||||
WHERE g.user_id = $1
|
WHERE g.user_id = $1
|
||||||
ORDER BY g.granted_at DESC, g.milestone DESC`,
|
-- NULLS LAST so gifts, which have no milestone, sort among the rest
|
||||||
|
-- by date rather than jumping to the top.
|
||||||
|
ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`,
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var g model.SadhnaRewardGrant
|
var g model.SadhnaRewardGrant
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&g.Milestone, &g.GrantedAt,
|
&g.Milestone, &g.GrantedAt, &g.Source, &g.Opened,
|
||||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -180,8 +187,106 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateCustom adds a reward to the catalog from the admin panel.
|
||||||
|
//
|
||||||
|
// Custom entries survive re-seeding: Seed upserts only the keys in its
|
||||||
|
// embedded JSON, and these keys are namespaced away from those.
|
||||||
|
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||||
|
var w model.SadhnaReward
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
|
||||||
|
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
|
||||||
|
VALUES ($1, $2, $3, $4, true, 1000)
|
||||||
|
RETURNING id, key, name, meaning, image_url, active, sort`,
|
||||||
|
key, name, meaning, imageURL,
|
||||||
|
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create custom reward: %w", err)
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCustom removes an admin-made reward from the catalog.
|
||||||
|
//
|
||||||
|
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
|
||||||
|
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
|
||||||
|
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
|
||||||
|
// the artwork onto the message rather than referencing the catalog, so history
|
||||||
|
// stays intact.
|
||||||
|
//
|
||||||
|
// Restricted to custom keys. A seeded reward would come straight back on the
|
||||||
|
// next Seed run, and deleting one would rip a streak reward out of the
|
||||||
|
// galleries of everyone who earned it.
|
||||||
|
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete custom reward: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRewardNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByKey looks up one reward from the catalog.
|
||||||
|
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
|
||||||
|
var w model.SadhnaReward
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, key, name, meaning, image_url, active, sort
|
||||||
|
FROM sadhna_rewards WHERE key = $1 AND active`, key,
|
||||||
|
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrRewardNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reward by key: %w", err)
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantGiftToEveryone hands one reward to every account, unopened.
|
||||||
|
//
|
||||||
|
// Idempotent by the partial unique index: re-running adds it only to people
|
||||||
|
// who don't have it, so a mis-click can't create duplicate tiles. Returns how
|
||||||
|
// many accounts it reached.
|
||||||
|
func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO sadhna_reward_grants (user_id, reward_id, source)
|
||||||
|
SELECT u.id, $1, 'gift'
|
||||||
|
FROM users u
|
||||||
|
WHERE u.banned_at IS NULL
|
||||||
|
ON CONFLICT DO NOTHING`, rewardID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("grant gift: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenGift marks one gift as seen, which is what un-blurs its tile.
|
||||||
|
//
|
||||||
|
// Scoped to a single reward rather than clearing everything pending: a devotee
|
||||||
|
// with two gifts waiting should open them one at a time.
|
||||||
|
func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE sadhna_reward_grants g
|
||||||
|
SET celebrated_at = now()
|
||||||
|
FROM sadhna_rewards w
|
||||||
|
WHERE w.id = g.reward_id
|
||||||
|
AND g.user_id = $1 AND w.key = $2
|
||||||
|
AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open gift: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PendingGrant returns the most recent grant the client hasn't celebrated
|
// PendingGrant returns the most recent grant the client hasn't celebrated
|
||||||
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
|
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
|
||||||
|
//
|
||||||
|
// STREAK grants only. An admin gift is also uncelebrated until opened, but it
|
||||||
|
// waits as a blurred tile in the gallery rather than ambushing the devotee
|
||||||
|
// with confetti the moment they load the page.
|
||||||
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
|
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
|
||||||
var g model.SadhnaRewardGrant
|
var g model.SadhnaRewardGrant
|
||||||
err := r.pg.Pool.QueryRow(ctx, `
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
@@ -189,7 +294,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
|
|||||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||||
FROM sadhna_reward_grants g
|
FROM sadhna_reward_grants g
|
||||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||||
WHERE g.user_id = $1 AND g.celebrated_at IS NULL
|
WHERE g.user_id = $1 AND g.celebrated_at IS NULL AND g.source = 'streak'
|
||||||
ORDER BY g.granted_at DESC, g.milestone DESC
|
ORDER BY g.granted_at DESC, g.milestone DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
userID,
|
userID,
|
||||||
@@ -198,6 +303,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
|
|||||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||||
)
|
)
|
||||||
|
g.Source = "streak"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil // no rows / nothing pending
|
return nil, nil // no rows / nothing pending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotStaff = errors.New("not a staff member")
|
||||||
|
ErrRoleNotFound = errors.New("role not found")
|
||||||
|
ErrRoleKeyTaken = errors.New("a role with that name already exists")
|
||||||
|
ErrRoleInUse = errors.New("role is still assigned to staff")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuperadminKey is the seeded role that holds the wildcard.
|
||||||
|
const SuperadminKey = "superadmin"
|
||||||
|
|
||||||
|
type StaffRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Access loads the caller's role and permissions. ErrNotStaff when the account
|
||||||
|
// holds no admin role at all.
|
||||||
|
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
|
||||||
|
var a model.StaffAccess
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
|
||||||
|
FROM users u
|
||||||
|
JOIN admin_roles ro ON ro.id = u.admin_role_id
|
||||||
|
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
|
||||||
|
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotStaff
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("staff access: %w", err)
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffSelect = `
|
||||||
|
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
|
||||||
|
ro.id, ro.key, ro.name, u.admin_blocked_at
|
||||||
|
FROM users u
|
||||||
|
JOIN admin_roles ro ON ro.id = u.admin_role_id`
|
||||||
|
|
||||||
|
func scanStaff(row pgx.Row, m *model.StaffMember) error {
|
||||||
|
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
|
||||||
|
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStaff returns every account holding a role, superadmins first.
|
||||||
|
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
|
||||||
|
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list staff: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.StaffMember{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m model.StaffMember
|
||||||
|
if err := scanStaff(rows, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan staff: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
|
||||||
|
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
|
||||||
|
var m model.StaffMember
|
||||||
|
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotStaff
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("staff member: %w", err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRole grants or changes a role. A nil roleID revokes staff access and
|
||||||
|
// clears any block, so a later re-grant starts clean.
|
||||||
|
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET admin_role_id = $2,
|
||||||
|
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`, userID, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set staff role: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrUserGone
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlocked suspends or restores a staff member's panel access.
|
||||||
|
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
|
||||||
|
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set staff blocked: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
|
||||||
|
// stops the last one from being demoted, revoked or blocked.
|
||||||
|
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM users u
|
||||||
|
JOIN admin_roles ro ON ro.id = u.admin_role_id
|
||||||
|
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
|
||||||
|
SuperadminKey).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count superadmins: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDByHandle resolves a handle for granting a role.
|
||||||
|
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrUserGone
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve handle: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
|
||||||
|
ro.permissions, ro.created_at,
|
||||||
|
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
|
||||||
|
FROM admin_roles ro
|
||||||
|
ORDER BY ro.is_system DESC,
|
||||||
|
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
|
||||||
|
ro.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list roles: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.AdminRole{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ro model.AdminRole
|
||||||
|
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
|
||||||
|
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan role: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, ro)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
|
||||||
|
var ro model.AdminRole
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, key, name, description, is_system, permissions, created_at
|
||||||
|
FROM admin_roles WHERE id = $1`, id,
|
||||||
|
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrRoleNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load role: %w", err)
|
||||||
|
}
|
||||||
|
return &ro, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
|
||||||
|
var ro model.AdminRole
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO admin_roles (key, name, description, permissions)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, key, name, description, is_system, permissions, created_at`,
|
||||||
|
key, name, description, perms,
|
||||||
|
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "admin_roles_key_key") {
|
||||||
|
return nil, ErrRoleKeyTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create role: %w", err)
|
||||||
|
}
|
||||||
|
return &ro, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
|
||||||
|
// bug higher up still can't rewrite superadmin.
|
||||||
|
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE admin_roles
|
||||||
|
SET name = $2, description = $3, permissions = $4, updated_at = now()
|
||||||
|
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update role: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRoleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole removes an unused CUSTOM role.
|
||||||
|
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
|
||||||
|
var members int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
|
||||||
|
return fmt.Errorf("count role members: %w", err)
|
||||||
|
}
|
||||||
|
if members > 0 {
|
||||||
|
return ErrRoleInUse
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete role: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRoleNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ type CategoryCount struct {
|
|||||||
// the table sizes are modest enough that chasing parallelism here
|
// the table sizes are modest enough that chasing parallelism here
|
||||||
// would buy sub-ms wins for added complexity.
|
// would buy sub-ms wins for added complexity.
|
||||||
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
||||||
s := &StatsSnapshot{}
|
// Non-nil so an empty table serialises as [] rather than null.
|
||||||
|
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
|
||||||
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
|
||||||
scalars := []struct {
|
scalars := []struct {
|
||||||
|
|||||||
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
|||||||
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
||||||
var total int
|
var total int
|
||||||
if err := r.pg.Pool.QueryRow(ctx,
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
|
`SELECT COUNT(*) FROM users
|
||||||
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
|
||||||
substring).Scan(&total); err != nil {
|
substring).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count search users: %w", err)
|
return nil, 0, fmt.Errorf("count search users: %w", err)
|
||||||
}
|
}
|
||||||
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
|||||||
// and name could reshuffle between pages.
|
// and name could reshuffle between pages.
|
||||||
sql := `SELECT ` + userColumns + `
|
sql := `SELECT ` + userColumns + `
|
||||||
FROM users
|
FROM users
|
||||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
|
||||||
|
-- Suspended accounts are unsearchable. This one query backs the
|
||||||
|
-- people search page, the mention autocomplete AND both chat member
|
||||||
|
-- pickers, so a banned account appearing here is offered as someone
|
||||||
|
-- to @mention or add to a group. ResolveHandles already refuses them
|
||||||
|
-- on the way in, which without this makes "Add" silently do nothing.
|
||||||
|
AND banned_at IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN handle::text ILIKE $2 THEN 0
|
WHEN handle::text ILIKE $2 THEN 0
|
||||||
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
|
|||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.created_at >= $2
|
WHERE u.created_at >= $2
|
||||||
AND u.id <> $1
|
AND u.id <> $1
|
||||||
|
-- Suspended accounts are not discoverable. A ban hides someone from
|
||||||
|
-- the app; surfacing them in the welcome strip would be the most
|
||||||
|
-- prominent placement on the home page.
|
||||||
|
AND u.banned_at IS NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM follows f
|
SELECT 1 FROM follows f
|
||||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import (
|
|||||||
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||||
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||||
// verses / prose_chapters / transcriptions collections).
|
// verses / prose_chapters / transcriptions collections).
|
||||||
|
var (
|
||||||
|
// ErrVaniRefInvalid is a malformed content id.
|
||||||
|
ErrVaniRefInvalid = errors.New("not a valid content reference")
|
||||||
|
// ErrVaniRefNotFound is a well-formed id that matches nothing.
|
||||||
|
ErrVaniRefNotFound = errors.New("that content no longer exists")
|
||||||
|
)
|
||||||
|
|
||||||
type VaniRepo struct {
|
type VaniRepo struct {
|
||||||
pg *Postgres
|
pg *Postgres
|
||||||
mongo *MongoDB
|
mongo *MongoDB
|
||||||
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert writes a day's pick, REPLACING any existing one.
|
||||||
|
//
|
||||||
|
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
|
||||||
|
// picker must never overwrite a day already decided. An admin choosing the day
|
||||||
|
// deliberately is the one case that should win.
|
||||||
|
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
|
||||||
|
b, err := json.Marshal(pick.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
|
ON CONFLICT (day) DO UPDATE
|
||||||
|
SET kind = EXCLUDED.kind,
|
||||||
|
ref_id = EXCLUDED.ref_id,
|
||||||
|
payload = EXCLUDED.payload`,
|
||||||
|
pick.Day, pick.Kind, pick.RefID, b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert vani pick: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes a day's pick, so the automatic picker chooses again.
|
||||||
|
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear vani pick: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PicksFrom lists decided days from `from` onward, for the admin schedule.
|
||||||
|
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx,
|
||||||
|
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
|
||||||
|
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list vani picks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.VaniPick{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.VaniPick
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan vani pick: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p.Payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||||
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||||
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||||
@@ -135,6 +200,107 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
|
|||||||
return false, cur.Err()
|
return false, cur.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oneByHex loads a single document by its _id hex. Shared by the three
|
||||||
|
// admin-pick lookups, which differ only in collection and target type.
|
||||||
|
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
|
||||||
|
oid, err := bson.ObjectIDFromHex(hex)
|
||||||
|
if err != nil {
|
||||||
|
return ErrVaniRefInvalid
|
||||||
|
}
|
||||||
|
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load %s %s: %w", coll, hex, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscriptBySlug loads a lecture by its URL slug.
|
||||||
|
//
|
||||||
|
// Search cannot do this: the slug is not an indexed field, so querying
|
||||||
|
// "720720sb-london" matches on tokens like "london" and returns whatever else
|
||||||
|
// mentions it. Resolving the slug directly is the only reliable way to pin a
|
||||||
|
// specific item someone already has the URL for.
|
||||||
|
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
|
||||||
|
var t TranscriptionDoc
|
||||||
|
err := r.mongo.Collection("transcriptions").
|
||||||
|
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("transcript by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProseBySlug loads a prose chapter by book and slug.
|
||||||
|
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
|
||||||
|
var p ProseChapterDoc
|
||||||
|
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
|
||||||
|
{Key: "book", Value: book},
|
||||||
|
{Key: "slug", Value: slug},
|
||||||
|
}).Decode(&p)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prose by slug: %w", err)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
|
||||||
|
// which is what a verse URL carries.
|
||||||
|
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
|
||||||
|
filter := bson.D{
|
||||||
|
{Key: "book", Value: book},
|
||||||
|
{Key: "chapter", Value: chapter},
|
||||||
|
{Key: "verse", Value: verse},
|
||||||
|
}
|
||||||
|
if division != nil && *division != "" {
|
||||||
|
filter = append(filter, bson.E{Key: "division", Value: *division})
|
||||||
|
}
|
||||||
|
var v VerseDoc
|
||||||
|
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return nil, ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verse by ref: %w", err)
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
||||||
|
// chosen. The payload is then built by the same builders the random picker
|
||||||
|
// uses, so a pinned pick renders identically to an automatic one.
|
||||||
|
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
|
||||||
|
var v VerseDoc
|
||||||
|
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
|
||||||
|
var p ProseChapterDoc
|
||||||
|
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
|
||||||
|
var t TranscriptionDoc
|
||||||
|
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SampleVerse returns a random verse with a translation, excluding the given
|
// SampleVerse returns a random verse with a translation, excluding the given
|
||||||
// _id hexes. Returns nil when nothing matches.
|
// _id hexes. Returns nil when nothing matches.
|
||||||
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||||
|
|||||||
+127
-38
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||||
@@ -78,6 +79,9 @@ type Server struct {
|
|||||||
statsH *handler.StatsHandler
|
statsH *handler.StatsHandler
|
||||||
adminH *handler.AdminHandler
|
adminH *handler.AdminHandler
|
||||||
broadcastH *handler.BroadcastHandler
|
broadcastH *handler.BroadcastHandler
|
||||||
|
staffRepo *repository.StaffRepo
|
||||||
|
staffH *handler.StaffHandler
|
||||||
|
quizH *handler.QuizHandler
|
||||||
|
|
||||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
authIssuer *auth.Issuer
|
authIssuer *auth.Issuer
|
||||||
@@ -224,6 +228,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||||
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
||||||
|
|
||||||
|
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
|
||||||
|
// target title and kind.
|
||||||
|
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
|
||||||
|
|
||||||
// OAuth — wired only when at least one provider is configured. The
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
// registry skips providers with missing creds, so partial configs (e.g.
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
// Google only, no Facebook) work out of the box.
|
// Google only, no Facebook) work out of the box.
|
||||||
@@ -266,7 +274,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
||||||
chatRepo := repository.NewChatRepo(s.pg)
|
chatRepo := repository.NewChatRepo(s.pg)
|
||||||
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
|
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
|
||||||
pushSender, notifPrefsRepo, userRepo, rtClient, r2, s.redis, logger)
|
pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
|
||||||
|
rtClient, r2, s.redis, logger)
|
||||||
s.chatH = handler.NewChatHandler(chatSvc)
|
s.chatH = handler.NewChatHandler(chatSvc)
|
||||||
|
|
||||||
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
|
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
|
||||||
@@ -327,7 +336,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
if err := rewardRepo.Seed(context.Background()); err != nil {
|
if err := rewardRepo.Seed(context.Background()); err != nil {
|
||||||
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
||||||
}
|
}
|
||||||
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger)
|
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, r2, logger)
|
||||||
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
||||||
|
|
||||||
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
||||||
@@ -343,7 +352,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// Gated by RequireAuth + RequireAdmin in the router.
|
// Gated by RequireAuth + RequireAdmin in the router.
|
||||||
adminRepo := repository.NewAdminRepo(s.pg)
|
adminRepo := repository.NewAdminRepo(s.pg)
|
||||||
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
||||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
// Admin RBAC: who is staff, with which permissions.
|
||||||
|
s.staffRepo = repository.NewStaffRepo(s.pg)
|
||||||
|
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
|
||||||
|
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
|
||||||
|
|
||||||
// Broadcasts — admin announcements shown once per user. No notification
|
// Broadcasts — admin announcements shown once per user. No notification
|
||||||
// rows: the modal is the surface, and push plus the realtime nudge only
|
// rows: the modal is the surface, and push plus the realtime nudge only
|
||||||
@@ -478,8 +490,14 @@ func (s *Server) setupRouter() {
|
|||||||
// CORS — the main frontend plus, when configured, the admin panel (a
|
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||||
// separate origin). Empty origins are filtered so the list never widens
|
// separate origin). Empty origins are filtered so the list never widens
|
||||||
// unintentionally.
|
// unintentionally.
|
||||||
|
adminURL := s.cfg.AdminURL
|
||||||
|
if adminURL == "" && s.cfg.Env == "development" {
|
||||||
|
// The admin app's dev server (`next dev -p 3001`), so local admin
|
||||||
|
// work needs no extra config. Production must set ADMIN_URL.
|
||||||
|
adminURL = "http://localhost:3001"
|
||||||
|
}
|
||||||
allowedOrigins := []string{}
|
allowedOrigins := []string{}
|
||||||
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
|
||||||
if o != "" {
|
if o != "" {
|
||||||
allowedOrigins = append(allowedOrigins, o)
|
allowedOrigins = append(allowedOrigins, o)
|
||||||
}
|
}
|
||||||
@@ -707,6 +725,23 @@ func (s *Server) setupRouter() {
|
|||||||
// Vāṇī of the Day — public daily content pick for the home page.
|
// Vāṇī of the Day — public daily content pick for the home page.
|
||||||
r.Get("/vani/today", s.vaniH.Today)
|
r.Get("/vani/today", s.vaniH.Today)
|
||||||
|
|
||||||
|
// Quizzes. Browsing is open to signed-out readers (they see that a
|
||||||
|
// quiz exists); taking one, and seeing results, needs an account.
|
||||||
|
r.Route("/quizzes", func(r chi.Router) {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/", s.quizH.List)
|
||||||
|
r.Get("/for", s.quizH.ForPath)
|
||||||
|
r.Get("/{id}", s.quizH.Get)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/{id}/start", s.quizH.Start)
|
||||||
|
r.Post("/{id}/answer", s.quizH.Answer)
|
||||||
|
r.Get("/{id}/result", s.quizH.Result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||||
@@ -935,6 +970,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/conversations", s.chatH.Inbox)
|
r.Get("/conversations", s.chatH.Inbox)
|
||||||
r.Get("/unread", s.chatH.Unread)
|
r.Get("/unread", s.chatH.Unread)
|
||||||
|
r.Get("/stickers", s.chatH.Stickers)
|
||||||
r.Post("/conversations/dm", s.chatH.OpenDM)
|
r.Post("/conversations/dm", s.chatH.OpenDM)
|
||||||
r.Post("/conversations/group", s.chatH.CreateGroup)
|
r.Post("/conversations/group", s.chatH.CreateGroup)
|
||||||
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
|
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
|
||||||
@@ -951,6 +987,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/messages", s.chatH.Messages)
|
r.Get("/messages", s.chatH.Messages)
|
||||||
r.Post("/messages", s.chatH.Send)
|
r.Post("/messages", s.chatH.Send)
|
||||||
r.Post("/read", s.chatH.MarkRead)
|
r.Post("/read", s.chatH.MarkRead)
|
||||||
|
r.Post("/typing", s.chatH.Typing)
|
||||||
r.Post("/delivered", s.chatH.MarkDelivered)
|
r.Post("/delivered", s.chatH.MarkDelivered)
|
||||||
r.Get("/receipts", s.chatH.Receipts)
|
r.Get("/receipts", s.chatH.Receipts)
|
||||||
r.Post("/mute", s.chatH.SetMute)
|
r.Post("/mute", s.chatH.SetMute)
|
||||||
@@ -961,6 +998,11 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/starred", s.chatH.Starred)
|
r.Get("/starred", s.chatH.Starred)
|
||||||
r.Post("/messages/{messageID}/star", s.chatH.Star)
|
r.Post("/messages/{messageID}/star", s.chatH.Star)
|
||||||
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
|
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
|
||||||
|
r.Get("/messages/{messageID}", s.chatH.Message)
|
||||||
|
r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions)
|
||||||
|
r.Post("/messages/{messageID}/sticker", s.chatH.SaveSticker)
|
||||||
|
r.Put("/messages/{messageID}/reaction", s.chatH.React)
|
||||||
|
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
|
||||||
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
|
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
|
||||||
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
|
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
|
||||||
r.Post("/forward", s.chatH.Forward)
|
r.Post("/forward", s.chatH.Forward)
|
||||||
@@ -1125,6 +1167,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/chant", s.sadhnaH.SaveChant)
|
r.Post("/chant", s.sadhnaH.SaveChant)
|
||||||
r.Get("/rewards", s.sadhnaH.Rewards)
|
r.Get("/rewards", s.sadhnaH.Rewards)
|
||||||
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
||||||
|
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
|
||||||
if s.reminderH != nil {
|
if s.reminderH != nil {
|
||||||
r.Get("/reminders", s.reminderH.GetPrefs)
|
r.Get("/reminders", s.reminderH.GetPrefs)
|
||||||
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
||||||
@@ -1141,41 +1184,87 @@ func (s *Server) setupRouter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
// Staff only past this point; each route then names the one
|
||||||
r.Get("/stats", s.adminH.Stats)
|
// permission it needs. A route without a permission is reachable
|
||||||
r.Get("/users", s.adminH.ListUsers)
|
// by every staff member, so only /me is left bare.
|
||||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
r.Use(middleware.RequireStaff(s.staffRepo))
|
||||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
can := func(perm string) chi.Router {
|
||||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
return r.With(middleware.RequirePermission(perm))
|
||||||
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
}
|
||||||
// Direct-disciple linking: mark an account as one of Śrīla
|
|
||||||
// Prabhupāda's initiated disciples (or clear the mark).
|
|
||||||
r.Get("/disciples", s.adminH.ListDisciples)
|
|
||||||
|
|
||||||
// Announcements composed and published from the admin panel.
|
r.Get("/me", s.staffH.Me)
|
||||||
r.Get("/broadcasts", s.broadcastH.List)
|
|
||||||
r.Post("/broadcasts", s.broadcastH.Create)
|
// Staff and roles — superadmins only.
|
||||||
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
|
||||||
r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
|
||||||
r.Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
|
||||||
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
|
||||||
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
|
||||||
r.Get("/posts", s.adminH.ListPosts)
|
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
|
||||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
|
||||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
|
||||||
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
|
||||||
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
|
||||||
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
|
||||||
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
|
||||||
r.Get("/collections", s.adminH.ListCollections)
|
can("stats:read").Get("/stats", s.adminH.Stats)
|
||||||
r.Get("/feedback", s.adminH.ListFeedback)
|
|
||||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
can("users:read").Get("/users", s.adminH.ListUsers)
|
||||||
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
r.Get("/reports", s.adminH.ListReports)
|
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||||
r.Get("/short-urls", s.adminH.ListShortURLs)
|
can("users:read").Get("/disciples", s.adminH.ListDisciples)
|
||||||
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||||
|
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||||
|
|
||||||
|
can("vani:read").Get("/vani", s.vaniH.Schedule)
|
||||||
|
can("vani:write").Post("/vani", s.vaniH.Set)
|
||||||
|
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
|
||||||
|
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
|
||||||
|
|
||||||
|
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||||
|
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||||
|
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||||
|
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||||
|
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
||||||
|
|
||||||
|
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
|
||||||
|
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
|
||||||
|
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
||||||
|
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
||||||
|
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
||||||
|
|
||||||
|
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
|
||||||
|
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||||
|
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||||
|
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
|
||||||
|
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||||
|
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||||
|
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||||
|
can("collections:read").Get("/collections", s.adminH.ListCollections)
|
||||||
|
|
||||||
|
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
|
||||||
|
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||||
|
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||||
|
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||||
|
|
||||||
|
can("reports:read").Get("/reports", s.adminH.ListReports)
|
||||||
|
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||||
|
|
||||||
|
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
|
||||||
|
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
|
||||||
|
can("quizzes:write").Post("/quizzes", s.quizH.Create)
|
||||||
|
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
|
||||||
|
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
|
||||||
|
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
|
||||||
|
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
|
||||||
|
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
||||||
|
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
||||||
|
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
||||||
|
|
||||||
|
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
||||||
|
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,7 +336,8 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||||
"RecipientName": name,
|
"RecipientName": name,
|
||||||
"Reply": message,
|
// Rendered markers; escaped by richtext, newlines become <br>.
|
||||||
|
"Reply": richtext.ToHTML(message),
|
||||||
"URL": s.frontendURL,
|
"URL": s.frontendURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||||
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
|
|||||||
|
|
||||||
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||||
|
|
||||||
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
<blockquote style="border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||||
|
|
||||||
<p style="margin:1.25em 0">
|
<p style="margin:1.25em 0">
|
||||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
|||||||
@@ -223,7 +223,9 @@ func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string
|
|||||||
if !ok || kind != model.AttachmentImage {
|
if !ok || kind != model.AttachmentImage {
|
||||||
return "", "", ErrChatBadMedia
|
return "", "", ErrChatBadMedia
|
||||||
}
|
}
|
||||||
return presignImage(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
|
// Returns the KEY: broadcasts.banner_key stores a key, and withBanner
|
||||||
|
// builds the URL from it at read time.
|
||||||
|
return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
|
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/unfurl"
|
"github.com/prabhupadasays/spsays-bff/pkg/unfurl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -43,6 +46,7 @@ var (
|
|||||||
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
|
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
|
||||||
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
|
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
|
||||||
ErrChatBadQuote = errors.New("that quote is not a valid passage reference")
|
ErrChatBadQuote = errors.New("that quote is not a valid passage reference")
|
||||||
|
ErrChatBadEmoji = errors.New("that is not a valid reaction")
|
||||||
ErrChatBadEntity = errors.New("that post cannot be shared")
|
ErrChatBadEntity = errors.New("that post cannot be shared")
|
||||||
ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months")
|
ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months")
|
||||||
ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option")
|
ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option")
|
||||||
@@ -71,6 +75,10 @@ type ChatService struct {
|
|||||||
push *PushSender
|
push *PushSender
|
||||||
prefs *repository.NotificationPrefsRepo
|
prefs *repository.NotificationPrefsRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
|
// mail nudges someone who has never used this thread and isn't connected.
|
||||||
|
// Optional: nil disables first-contact email and nothing else.
|
||||||
|
mail mailer.Mailer
|
||||||
|
frontendURL string
|
||||||
rt *realtime.SoketiClient
|
rt *realtime.SoketiClient
|
||||||
r2 *repository.R2
|
r2 *repository.R2
|
||||||
links *unfurl.Fetcher
|
links *unfurl.Fetcher
|
||||||
@@ -85,6 +93,8 @@ func NewChatService(
|
|||||||
push *PushSender,
|
push *PushSender,
|
||||||
prefs *repository.NotificationPrefsRepo,
|
prefs *repository.NotificationPrefsRepo,
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
|
mail mailer.Mailer,
|
||||||
|
frontendURL string,
|
||||||
rt *realtime.SoketiClient,
|
rt *realtime.SoketiClient,
|
||||||
r2 *repository.R2,
|
r2 *repository.R2,
|
||||||
cache *repository.Redis,
|
cache *repository.Redis,
|
||||||
@@ -92,7 +102,8 @@ func NewChatService(
|
|||||||
) *ChatService {
|
) *ChatService {
|
||||||
return &ChatService{
|
return &ChatService{
|
||||||
chat: chat, blocks: blocks, posts: posts,
|
chat: chat, blocks: blocks, posts: posts,
|
||||||
push: push, prefs: prefs, users: users, rt: rt, r2: r2,
|
push: push, prefs: prefs, users: users, mail: mail,
|
||||||
|
frontendURL: frontendURL, rt: rt, r2: r2,
|
||||||
links: unfurl.New(), cache: cache, logger: logger,
|
links: unfurl.New(), cache: cache, logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,6 +361,12 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// One grouped query for the whole page. Counts depend on the viewer
|
||||||
|
// (`mine`), which is why this is hydrated per request rather than cached.
|
||||||
|
reactions, err := s.chat.ReactionsFor(ctx, ids, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
if a := byMessage[msgs[i].ID]; a != nil {
|
if a := byMessage[msgs[i].ID]; a != nil {
|
||||||
msgs[i].Attachments = a
|
msgs[i].Attachments = a
|
||||||
@@ -357,6 +374,11 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, m
|
|||||||
// Always a slice, never nil, so clients need no guard.
|
// Always a slice, never nil, so clients need no guard.
|
||||||
msgs[i].Attachments = []model.Attachment{}
|
msgs[i].Attachments = []model.Attachment{}
|
||||||
}
|
}
|
||||||
|
if r := reactions[msgs[i].ID]; r != nil {
|
||||||
|
msgs[i].Reactions = r
|
||||||
|
} else {
|
||||||
|
msgs[i].Reactions = []model.Reaction{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pages := make([][]model.Attachment, 0, len(msgs))
|
pages := make([][]model.Attachment, 0, len(msgs))
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
@@ -406,6 +428,9 @@ type SendRequest struct {
|
|||||||
PreviewURL *string
|
PreviewURL *string
|
||||||
// Entity is something else in the app — a post — shared as a card.
|
// Entity is something else in the app — a post — shared as a card.
|
||||||
Entity *EntityAttachment
|
Entity *EntityAttachment
|
||||||
|
// StickerKey sends a piece of reward artwork. Ownership is checked
|
||||||
|
// server-side; the key alone proves nothing.
|
||||||
|
StickerKey *string
|
||||||
// Quote is a passage shared from the library, rendered as a quote the
|
// Quote is a passage shared from the library, rendered as a quote the
|
||||||
// reader can tap to return to the exact phrase.
|
// reader can tap to return to the exact phrase.
|
||||||
Quote *QuoteAttachment
|
Quote *QuoteAttachment
|
||||||
@@ -446,7 +471,8 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
|||||||
body := strings.TrimSpace(req.Body)
|
body := strings.TrimSpace(req.Body)
|
||||||
// A photo or a shared passage with no caption is an ordinary message; only
|
// A photo or a shared passage with no caption is an ordinary message; only
|
||||||
// a message with nothing at all in it is empty.
|
// a message with nothing at all in it is empty.
|
||||||
if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil {
|
if body == "" && len(req.Attachments) == 0 && req.Quote == nil &&
|
||||||
|
req.Entity == nil && req.StickerKey == nil {
|
||||||
return nil, ErrChatEmptyBody
|
return nil, ErrChatEmptyBody
|
||||||
}
|
}
|
||||||
if len([]rune(body)) > maxBodyRunes {
|
if len([]rune(body)) > maxBodyRunes {
|
||||||
@@ -478,9 +504,15 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
|||||||
// the first time, and re-publishing would duplicate the bubble for
|
// the first time, and re-publishing would duplicate the bubble for
|
||||||
// everyone else.
|
// everyone else.
|
||||||
if !created {
|
if !created {
|
||||||
return s.withAttachments(ctx, userID, msg)
|
return s.hydrateSent(ctx, userID, conversationID, msg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.StickerKey != nil {
|
||||||
|
if err := s.attachSticker(ctx, conversationID, userID, msg, *req.StickerKey); err != nil {
|
||||||
|
s.logger.Warn("chat: sticker refused",
|
||||||
|
zap.String("user_id", userID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil {
|
if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil {
|
||||||
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
|
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -488,14 +520,33 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
|||||||
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
|
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
|
||||||
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
|
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
|
||||||
|
|
||||||
full, err := s.withAttachments(ctx, userID, msg)
|
full := s.hydrateSent(ctx, userID, conversationID, msg)
|
||||||
if err != nil {
|
|
||||||
full = msg
|
|
||||||
}
|
|
||||||
s.publish(ctx, conversationID, userID, full)
|
s.publish(ctx, conversationID, userID, full)
|
||||||
return full, nil
|
return full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hydrateSent re-reads a just-written message through the full read path.
|
||||||
|
//
|
||||||
|
// Append RETURNINGs the row it inserted, which carries reply_to_id but not the
|
||||||
|
// quoted preview — that is built by a LATERAL join only the read query has. So
|
||||||
|
// a reply sent over the wire, and the realtime event announcing it, would both
|
||||||
|
// arrive with nothing to quote and stay that way until the thread was
|
||||||
|
// reloaded. Reading it back costs one query and makes the sent message
|
||||||
|
// identical to the one everyone else receives.
|
||||||
|
func (s *ChatService) hydrateSent(ctx context.Context, viewerID, conversationID string, msg *model.Message) *model.Message {
|
||||||
|
full, err := s.chat.Message(ctx, conversationID, msg.ID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to the unhydrated row: a missing quote is better than a
|
||||||
|
// message that appears not to have sent at all.
|
||||||
|
full = msg
|
||||||
|
}
|
||||||
|
hydrated, err := s.withAttachments(ctx, viewerID, full)
|
||||||
|
if err != nil {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
return hydrated
|
||||||
|
}
|
||||||
|
|
||||||
// withAttachments hydrates a message's attachments for the response.
|
// withAttachments hydrates a message's attachments for the response.
|
||||||
func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) {
|
func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) {
|
||||||
byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID})
|
byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID})
|
||||||
@@ -506,6 +557,12 @@ func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg
|
|||||||
if msg.Attachments == nil {
|
if msg.Attachments == nil {
|
||||||
msg.Attachments = []model.Attachment{}
|
msg.Attachments = []model.Attachment{}
|
||||||
}
|
}
|
||||||
|
if reactions, rErr := s.chat.ReactionsFor(ctx, []string{msg.ID}, viewerID); rErr == nil {
|
||||||
|
msg.Reactions = reactions[msg.ID]
|
||||||
|
}
|
||||||
|
if msg.Reactions == nil {
|
||||||
|
msg.Reactions = []model.Reaction{}
|
||||||
|
}
|
||||||
s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments})
|
s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments})
|
||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
@@ -1033,10 +1090,14 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if created {
|
if created {
|
||||||
full, hErr := s.withAttachments(ctx, userID, msg)
|
// Carry the attachments across. Without this a quote, sticker, photo
|
||||||
if hErr == nil {
|
// or shared post forwards as an empty bubble: its content lives in an
|
||||||
msg = full
|
// attachment row, not in the body.
|
||||||
|
if err := s.chat.CopyAttachments(ctx, messageID, msg.ID, toConversationID); err != nil {
|
||||||
|
s.logger.Warn("chat: forwarding attachments failed",
|
||||||
|
zap.String("message_id", messageID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
msg = s.hydrateSent(ctx, userID, toConversationID, msg)
|
||||||
s.publish(ctx, toConversationID, userID, msg)
|
s.publish(ctx, toConversationID, userID, msg)
|
||||||
}
|
}
|
||||||
return msg, nil
|
return msg, nil
|
||||||
@@ -1552,6 +1613,191 @@ func (s *ChatService) IsMuted(ctx context.Context, userID, conversationID string
|
|||||||
return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil
|
return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HeartEmoji is what a double-tap sends, and the one reaction with its own
|
||||||
|
// notification copy.
|
||||||
|
const HeartEmoji = "❤️"
|
||||||
|
|
||||||
|
// maxEmojiRunes bounds a reaction. Generous enough for a flag or a skin-tone
|
||||||
|
// sequence, small enough that the column can't be used as free text storage.
|
||||||
|
const maxEmojiRunes = 8
|
||||||
|
|
||||||
|
// MessageReactions lists who reacted to a message and with what.
|
||||||
|
//
|
||||||
|
// Authorized against the message's OWN conversation, so a message id alone
|
||||||
|
// never reveals who is in a thread the caller isn't part of.
|
||||||
|
func (s *ChatService) MessageReactions(ctx context.Context, userID, messageID string) ([]model.ReactionDetail, error) {
|
||||||
|
conversationID, _, err := s.chat.MessageConversation(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.chat.ReactionDetails(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message returns one hydrated message for this viewer.
|
||||||
|
//
|
||||||
|
// Exists because reaction counts carry a per-viewer `mine` flag, so a client
|
||||||
|
// told "message X changed" cannot compute the new chips itself — it has to ask.
|
||||||
|
func (s *ChatService) Message(ctx context.Context, userID, conversationID, messageID string) (*model.Message, error) {
|
||||||
|
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg, err := s.chat.Message(ctx, conversationID, messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.withAttachments(ctx, userID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stickers lists what the caller can send: sādhana rewards they earned, plus
|
||||||
|
// stickers they saved from a chat.
|
||||||
|
func (s *ChatService) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
|
||||||
|
return s.chat.Stickers(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSticker keeps one someone sent, after checking it really is on that
|
||||||
|
// message and the caller is in the thread.
|
||||||
|
func (s *ChatService) SaveSticker(ctx context.Context, userID, messageID, key string) error {
|
||||||
|
return s.chat.SaveSticker(ctx, userID, key, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachSticker stores the artwork on a message.
|
||||||
|
//
|
||||||
|
// Ownership is checked here, not trusted from the client: the key comes over
|
||||||
|
// the wire, and without this anyone could send any sticker in the catalog by
|
||||||
|
// guessing its key. The image and name are denormalized because the catalog
|
||||||
|
// is static art — there is nothing viewer-dependent to re-resolve on read.
|
||||||
|
func (s *ChatService) attachSticker(ctx context.Context, conversationID, userID string, msg *model.Message, key string) error {
|
||||||
|
sticker, err := s.chat.Sticker(ctx, userID, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entityType := model.EntitySticker
|
||||||
|
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
|
||||||
|
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
|
||||||
|
Kind: model.AttachmentSticker, EntityType: &entityType,
|
||||||
|
EntityID: &sticker.Key, ImageURL: &sticker.ImageURL, Title: &sticker.Name,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("chat: attach sticker failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing broadcasts that someone is composing.
|
||||||
|
//
|
||||||
|
// Never persisted and never pushed: a typing indicator is only meaningful to
|
||||||
|
// someone already looking at the thread, and it is stale within seconds.
|
||||||
|
// Publishing failures are ignored for the same reason.
|
||||||
|
func (s *ChatService) Typing(ctx context.Context, userID, conversationID string) error {
|
||||||
|
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.rt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "typing", map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Debug("chat: typing publish failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// React sets the caller's emoji on a message, replacing any previous one.
|
||||||
|
//
|
||||||
|
// Authorization is by membership of the message's OWN conversation, read back
|
||||||
|
// from the insert — a message id alone must not let someone react into a
|
||||||
|
// thread they were never in.
|
||||||
|
func (s *ChatService) React(ctx context.Context, userID, messageID, emoji string) error {
|
||||||
|
emoji = strings.TrimSpace(emoji)
|
||||||
|
if emoji == "" || len([]rune(emoji)) > maxEmojiRunes {
|
||||||
|
return ErrChatBadEmoji
|
||||||
|
}
|
||||||
|
conversationID, author, err := s.chat.MessageConversation(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.chat.SetReaction(ctx, messageID, userID, emoji); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishReaction(ctx, conversationID, messageID)
|
||||||
|
s.notifyReaction(ctx, conversationID, author, userID, emoji)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreact removes the caller's reaction.
|
||||||
|
func (s *ChatService) Unreact(ctx context.Context, userID, messageID string) error {
|
||||||
|
conversationID, _, err := s.chat.MessageConversation(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.chat.ClearReaction(ctx, messageID, userID); err != nil {
|
||||||
|
// Nothing to remove is the desired end state, not a failure.
|
||||||
|
if errors.Is(err, repository.ErrMessageNotHere) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishReaction(ctx, conversationID, messageID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishReaction tells the open thread to refresh one message's chips. The
|
||||||
|
// event carries only the id: counts depend on the viewer, so each client
|
||||||
|
// re-reads its own.
|
||||||
|
func (s *ChatService) publishReaction(ctx context.Context, conversationID, messageID string) {
|
||||||
|
if s.rt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "reaction", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Debug("chat: reaction publish failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyReaction pushes to the message's author.
|
||||||
|
//
|
||||||
|
// Never for reacting to your own message, and it reuses the chat push rules —
|
||||||
|
// mute, the Messages preference, presence and the debounce — so a reaction
|
||||||
|
// cannot become a way around them.
|
||||||
|
func (s *ChatService) notifyReaction(ctx context.Context, conversationID string, author *string, actorID, emoji string) {
|
||||||
|
if s.push == nil || !s.push.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No author on system and assistant messages, and reacting to your own
|
||||||
|
// message must never notify you.
|
||||||
|
if author == nil || *author == actorID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv, err := s.chat.Conversation(ctx, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := "Someone"
|
||||||
|
if s.users != nil {
|
||||||
|
if u, uErr := s.users.FindByID(ctx, actorID); uErr == nil && u != nil && u.Name != "" {
|
||||||
|
actor = u.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := actor + " reacted " + emoji + " to your message"
|
||||||
|
if emoji == HeartEmoji {
|
||||||
|
body = actor + " liked your message"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushTo(ctx, conv, *author, actor, body)
|
||||||
|
}
|
||||||
|
|
||||||
// Receipts reports how far EVERY other participant has got, which is what
|
// Receipts reports how far EVERY other participant has got, which is what
|
||||||
// draws the caller's tick states.
|
// draws the caller's tick states.
|
||||||
//
|
//
|
||||||
@@ -1697,29 +1943,112 @@ func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversa
|
|||||||
if c.MutedUntil != nil && c.MutedUntil.After(now) {
|
if c.MutedUntil != nil && c.MutedUntil.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// First contact: push only reaches a device that has already opted
|
||||||
|
// in, which someone who has never used chat almost certainly hasn't.
|
||||||
|
// Email is the one channel that reaches them — and only the FIRST
|
||||||
|
// message qualifies, since after that the thread exists and push
|
||||||
|
// carries it.
|
||||||
|
if msg.Seq == 1 && !s.online(ctx, c.UserID) {
|
||||||
|
s.emailFirstContact(ctx, c.UserID, senderID, conv)
|
||||||
|
}
|
||||||
|
s.pushTo(ctx, conv, c.UserID, title, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushTo delivers one chat notification, applying the suppression rules every
|
||||||
|
// chat push obeys: the Messages preference, a live connection, and the
|
||||||
|
// per-thread debounce. Reactions go through here too, so they cannot become a
|
||||||
|
// way around any of them.
|
||||||
|
//
|
||||||
|
// Mute is checked by callers, since they already hold the participant row.
|
||||||
|
func (s *ChatService) pushTo(ctx context.Context, conv *model.Conversation, userID, title, body string) {
|
||||||
|
if s.push == nil || !s.push.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.prefs != nil {
|
if s.prefs != nil {
|
||||||
if off, err := s.prefs.DisabledSet(ctx, c.UserID); err == nil &&
|
if off, err := s.prefs.DisabledSet(ctx, userID); err == nil &&
|
||||||
prefOff(off, NotifChatMessage, repository.ChannelPush) {
|
prefOff(off, NotifChatMessage, repository.ChannelPush) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.online(ctx, c.UserID) {
|
if s.online(ctx, userID) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if !s.claimPushSlot(ctx, c.UserID, conv.ID) {
|
if !s.claimPushSlot(ctx, userID, conv.ID) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
s.push.SendToUser(ctx, c.UserID, PushPayload{
|
s.push.SendToUser(ctx, userID, PushPayload{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
URL: "/chats/" + conv.ID,
|
URL: "/chats/" + conv.ID,
|
||||||
// Same tag per thread: a later message replaces the earlier
|
// Same tag per thread: a later notification replaces the earlier one
|
||||||
// notification rather than stacking up.
|
// rather than stacking up.
|
||||||
Tag: "chat:" + conv.ID,
|
Tag: "chat:" + conv.ID,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailFirstContact tells someone a conversation has been opened with them,
|
||||||
|
// when nothing else would reach them.
|
||||||
|
//
|
||||||
|
// Deliberately says nothing about the message itself: an email subject line is
|
||||||
|
// the least private surface in the product, and "you have a message" is enough
|
||||||
|
// to bring someone back.
|
||||||
|
func (s *ChatService) emailFirstContact(ctx context.Context, recipientID, senderID string, conv *model.Conversation) {
|
||||||
|
if s.mail == nil || s.users == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The same preference as chat push, on its email channel — so turning
|
||||||
|
// Messages off silences both rather than leaving one leaking through.
|
||||||
|
if s.prefs != nil {
|
||||||
|
if off, err := s.prefs.DisabledSet(ctx, recipientID); err == nil &&
|
||||||
|
prefOff(off, NotifChatMessage, repository.ChannelEmail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||||
|
if err != nil || recipient == nil ||
|
||||||
|
recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||||
|
// OAuth-only accounts may have no address on file.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sender, err := s.users.FindByID(ctx, senderID)
|
||||||
|
if err != nil || sender == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := firstContactEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": recipient.Name,
|
||||||
|
"SenderName": actorDisplay(sender),
|
||||||
|
"SenderHandle": sender.Handle,
|
||||||
|
"ChatURL": s.frontendURL + "/chats/" + conv.ID,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("chat: first-contact email render failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := actorDisplay(sender) + " sent you a message — Prabhupāda Says"
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||||||
|
s.logger.Warn("chat: first-contact email failed",
|
||||||
|
zap.String("recipient", recipientID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Styled to match the follow-request email so the two read as one product.
|
||||||
|
var firstContactEmailTpl = template.Must(template.New("firstContact").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p><strong>{{.SenderName}}</strong> (@{{.SenderHandle}}) has sent you a message.
|
||||||
|
Reply to them on Prabhupāda Says.</p>
|
||||||
|
|
||||||
|
<p style="margin:1.6em 0">
|
||||||
|
<a href="{{.ChatURL}}" style="background:#9a6b2f;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;display:inline-block">Open the conversation</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size:14px;color:#7a6a55">You are receiving this because someone started a conversation with you.
|
||||||
|
You can turn these off under Settings → Notifications.</p>
|
||||||
|
</div>`))
|
||||||
|
|
||||||
// pushCopy builds the lock-screen text.
|
// pushCopy builds the lock-screen text.
|
||||||
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
|
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
|
||||||
sender := "Someone"
|
sender := "Someone"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit
|
|||||||
// no-ops — these tests are about the rules, and none of those services are
|
// no-ops — these tests are about the rules, and none of those services are
|
||||||
// reachable from a test run.
|
// reachable from a test run.
|
||||||
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
|
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
|
||||||
nil, nil, nil, nil, nil, nil, zap.NewNop()), chat, pg
|
nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()), chat, pg
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
|
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
|
||||||
@@ -1928,7 +1928,7 @@ func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
|
|||||||
|
|
||||||
pg := &repository.Postgres{Pool: pool}
|
pg := &repository.Postgres{Pool: pool}
|
||||||
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
|
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
|
||||||
repository.NewPostRepo(pg), nil, nil, nil, nil, nil, nil, zap.NewNop())
|
repository.NewPostRepo(pg), nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop())
|
||||||
|
|
||||||
// measure returns the statement count for reading a thread carrying
|
// measure returns the statement count for reading a thread carrying
|
||||||
// `shares` distinct shared posts.
|
// `shares` distinct shared posts.
|
||||||
@@ -2204,3 +2204,501 @@ func TestReceipts_TickProgression(t *testing.T) {
|
|||||||
t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back)
|
t.Errorf("a stale ack rewound the cursors: %+v then %+v", rc, back)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reactionsOf(t *testing.T, msgs []model.Message, id string) []model.Reaction {
|
||||||
|
t.Helper()
|
||||||
|
for i := range msgs {
|
||||||
|
if msgs[i].ID == id {
|
||||||
|
return msgs[i].Reactions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reacting again replaces rather than stacks, and the chip knows whose it is.
|
||||||
|
func TestReactions_ReplaceAndToggle(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hare kṛṣṇa"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.React(ctx, b, msg.ID, HeartEmoji); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgs, _ := svc.Messages(ctx, b, conv.ID, 0, 10)
|
||||||
|
got := reactionsOf(t, msgs, msg.ID)
|
||||||
|
if len(got) != 1 || got[0].Emoji != HeartEmoji || got[0].Count != 1 || !got[0].Mine {
|
||||||
|
t.Fatalf("after reacting: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The author sees the same reaction, but it is not theirs.
|
||||||
|
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||||
|
got = reactionsOf(t, msgs, msg.ID)
|
||||||
|
if len(got) != 1 || got[0].Mine {
|
||||||
|
t.Errorf("author's view: %+v — Mine should be false", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing your mind replaces; it must not accumulate.
|
||||||
|
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
|
||||||
|
got = reactionsOf(t, msgs, msg.ID)
|
||||||
|
if len(got) != 1 || got[0].Emoji != "😂" {
|
||||||
|
t.Errorf("after changing: %+v — want exactly one, the new emoji", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing leaves nothing, and removing twice is harmless.
|
||||||
|
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Unreact(ctx, b, msg.ID); err != nil {
|
||||||
|
t.Errorf("second unreact should be a no-op, got %v", err)
|
||||||
|
}
|
||||||
|
msgs, _ = svc.Messages(ctx, b, conv.ID, 0, 10)
|
||||||
|
if got = reactionsOf(t, msgs, msg.ID); len(got) != 0 {
|
||||||
|
t.Errorf("after removing: %+v — want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts group across people, and each viewer sees only their own as "mine".
|
||||||
|
func TestReactions_GroupAcrossPeople(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
|
||||||
|
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range []string{a, b} {
|
||||||
|
if err := svc.React(ctx, u, msg.ID, HeartEmoji); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||||
|
got := reactionsOf(t, msgs, msg.ID)
|
||||||
|
if len(got) != 1 || got[0].Count != 2 || !got[0].Mine {
|
||||||
|
t.Errorf("A's view: %+v — want one chip, count 2, mine", got)
|
||||||
|
}
|
||||||
|
msgs, _ = svc.Messages(ctx, owner, conv.ID, 0, 10)
|
||||||
|
got = reactionsOf(t, msgs, msg.ID)
|
||||||
|
if len(got) != 1 || got[0].Count != 2 || got[0].Mine {
|
||||||
|
t.Errorf("owner's view: %+v — count 2 but not mine", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message id alone must not let an outsider react into a thread they were
|
||||||
|
// never part of.
|
||||||
|
func TestReactions_OutsiderRefused(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "private"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.React(ctx, outsider, msg.ID, HeartEmoji); !errors.Is(err, repository.ErrNotParticipant) {
|
||||||
|
t.Errorf("outsider react: err = %v, want ErrNotParticipant", err)
|
||||||
|
}
|
||||||
|
if err := svc.Typing(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) {
|
||||||
|
t.Errorf("outsider typing: err = %v, want ErrNotParticipant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An emoji column is not free text storage.
|
||||||
|
func TestReactions_RejectsJunk(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"", " ", strings.Repeat("a", 64)} {
|
||||||
|
if err := svc.React(ctx, b, msg.ID, bad); !errors.Is(err, ErrChatBadEmoji) {
|
||||||
|
t.Errorf("React(%q): err = %v, want ErrChatBadEmoji", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "who reacted" list names each person and their emoji, and is readable
|
||||||
|
// only by people in the thread.
|
||||||
|
func TestReactionDetails_ListsEveryoneAndGuardsOutsiders(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
owner, a, b := chatUser(t, pg, "O"), chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
outsider := chatUser(t, pg, "X")
|
||||||
|
conv, err := svc.CreateGroup(ctx, owner, "Kīrtana",
|
||||||
|
[]string{handleOf(t, pg, a), handleOf(t, pg, b)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
msg, err := svc.Send(ctx, owner, conv.ID, SendRequest{Body: "a verse"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.React(ctx, a, msg.ID, HeartEmoji); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.React(ctx, b, msg.ID, "😂"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := svc.MessageReactions(ctx, owner, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("list = %d entries, want 2", len(list))
|
||||||
|
}
|
||||||
|
byUser := map[string]string{}
|
||||||
|
for _, d := range list {
|
||||||
|
byUser[d.User.ID] = d.Emoji
|
||||||
|
if d.User.Handle == "" || d.User.Name == "" {
|
||||||
|
t.Errorf("entry %+v is missing the details needed to render a row", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if byUser[a] != HeartEmoji || byUser[b] != "😂" {
|
||||||
|
t.Errorf("emoji did not follow their authors: %+v", byUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing your mind replaces your entry rather than adding one.
|
||||||
|
if err := svc.React(ctx, a, msg.ID, "🙏"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = svc.MessageReactions(ctx, owner, msg.ID)
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Errorf("after changing: %d entries, want 2", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowing a message id must not reveal who is in a thread you're not in.
|
||||||
|
if _, err := svc.MessageReactions(ctx, outsider, msg.ID); !errors.Is(err, repository.ErrNotParticipant) {
|
||||||
|
t.Errorf("outsider: err = %v, want ErrNotParticipant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply must arrive with its quoted preview already attached. Append only
|
||||||
|
// RETURNINGs reply_to_id, so without a read-back the sender — and everyone
|
||||||
|
// receiving the realtime event — would see a reply quoting nothing until the
|
||||||
|
// thread was reloaded.
|
||||||
|
func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
|
||||||
|
original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reply, err := svc.Send(ctx, b, conv.ID, SendRequest{
|
||||||
|
Body: "answering", ReplyToID: &original.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.ReplyTo == nil {
|
||||||
|
t.Fatal("the send response has no quoted preview — the client has nothing to render")
|
||||||
|
}
|
||||||
|
if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" {
|
||||||
|
t.Errorf("quote = %+v, want the original message", reply.ReplyTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a retried send must return the same shape, not a bare row.
|
||||||
|
clientID := uuid.NewString()
|
||||||
|
first, err := svc.Send(ctx, b, conv.ID, SendRequest{
|
||||||
|
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retry, err := svc.Send(ctx, b, conv.ID, SendRequest{
|
||||||
|
Body: "once", ReplyToID: &original.ID, ClientMsgID: &clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if retry.ID != first.ID {
|
||||||
|
t.Fatalf("retry created a second message: %s vs %s", retry.ID, first.ID)
|
||||||
|
}
|
||||||
|
if retry.ReplyTo == nil {
|
||||||
|
t.Error("a retried reply lost its quote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantSticker gives a user a reward the way sādhana does, returning its key.
|
||||||
|
func grantSticker(t *testing.T, pg *repository.Postgres, userID, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var rewardID string
|
||||||
|
if err := pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO sadhna_rewards (key, name, image_url)
|
||||||
|
VALUES ($1, $2, 'https://example.invalid/art.png')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`, key, "Sticker "+key).Scan(&rewardID); err != nil {
|
||||||
|
t.Fatalf("create reward: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(ctx, `DELETE FROM sadhna_rewards WHERE id = $1`, rewardID)
|
||||||
|
})
|
||||||
|
if userID != "" {
|
||||||
|
// milestone is unique per user; a large random-ish one avoids clashing
|
||||||
|
// with any real streak rows.
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
userID, rewardID, 900000+len(key)); err != nil {
|
||||||
|
t.Fatalf("grant reward: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func stickerAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
|
||||||
|
t.Helper()
|
||||||
|
for i := range msgs {
|
||||||
|
for j := range msgs[i].Attachments {
|
||||||
|
if msgs[i].Attachments[j].Kind == model.AttachmentSticker {
|
||||||
|
return &msgs[i].Attachments[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sticker key is not a capability: sending one you never earned must fail,
|
||||||
|
// or the whole catalog is available to anyone who can guess a key.
|
||||||
|
func TestStickers_OnlySendWhatYouOwn(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
|
||||||
|
mine := grantSticker(t, pg, a, "sticker_owned_"+a[:8])
|
||||||
|
// Exists in the catalog, granted to nobody.
|
||||||
|
unearned := grantSticker(t, pg, "", "sticker_unearned_"+a[:8])
|
||||||
|
|
||||||
|
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &mine}); err != nil {
|
||||||
|
t.Fatalf("sending an earned sticker: %v", err)
|
||||||
|
}
|
||||||
|
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||||
|
att := stickerAttachment(t, msgs)
|
||||||
|
if att == nil {
|
||||||
|
t.Fatal("an earned sticker did not attach")
|
||||||
|
}
|
||||||
|
if att.ImageURL == nil || *att.ImageURL == "" || att.EntityID == nil || *att.EntityID != mine {
|
||||||
|
t.Errorf("attachment = %+v, want the artwork and its key", att)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not earned, not saved: the message sends, but carries no sticker.
|
||||||
|
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
|
||||||
|
Body: "trying", StickerKey: &unearned,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||||
|
for i := range msgs {
|
||||||
|
for j := range msgs[i].Attachments {
|
||||||
|
if att := msgs[i].Attachments[j]; att.Kind == model.AttachmentSticker &&
|
||||||
|
att.EntityID != nil && *att.EntityID == unearned {
|
||||||
|
t.Error("a sticker the sender never earned was attached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving is how a sticker spreads — but only from a message actually sent to
|
||||||
|
// you, in a thread you are actually in.
|
||||||
|
func TestStickers_SaveFromChat(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
|
||||||
|
conv, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, conv.ID)
|
||||||
|
|
||||||
|
key := grantSticker(t, pg, a, "sticker_shared_"+a[:8])
|
||||||
|
sent, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &key})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// B has not earned it, so it isn't in their drawer yet.
|
||||||
|
before, _ := svc.Stickers(ctx, b)
|
||||||
|
for _, s := range before {
|
||||||
|
if s.Key == key {
|
||||||
|
t.Fatal("B has the sticker before saving it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
|
||||||
|
t.Fatalf("saving a sticker sent to you: %v", err)
|
||||||
|
}
|
||||||
|
after, _ := svc.Stickers(ctx, b)
|
||||||
|
var found *model.Sticker
|
||||||
|
for i := range after {
|
||||||
|
if after[i].Key == key {
|
||||||
|
found = &after[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("saved sticker is not in B's drawer")
|
||||||
|
}
|
||||||
|
if !found.Saved {
|
||||||
|
t.Error("a sticker from a chat should be marked as saved, not earned")
|
||||||
|
}
|
||||||
|
// B can now send it themselves.
|
||||||
|
if _, err := svc.Send(ctx, b, conv.ID, SendRequest{StickerKey: &key}); err != nil {
|
||||||
|
t.Errorf("B should be able to send a saved sticker: %v", err)
|
||||||
|
}
|
||||||
|
// Saving twice is harmless.
|
||||||
|
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
|
||||||
|
t.Errorf("second save should be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone outside the thread cannot save from it, even knowing both ids.
|
||||||
|
if err := svc.SaveSticker(ctx, outsider, sent.ID, key); !errors.Is(err, repository.ErrStickerNotOwned) {
|
||||||
|
t.Errorf("outsider save: err = %v, want ErrStickerNotOwned", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sender's own drawer still reports it as earned, not saved.
|
||||||
|
mine, _ := svc.Stickers(ctx, a)
|
||||||
|
for _, s := range mine {
|
||||||
|
if s.Key == key && s.Saved {
|
||||||
|
t.Error("an earned sticker is being reported as saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quote, sticker, photo or shared post lives in an attachment row, not in
|
||||||
|
// the body — so forwarding one has to carry the attachments across or the
|
||||||
|
// recipient gets an empty bubble.
|
||||||
|
func TestForward_CarriesAttachments(t *testing.T) {
|
||||||
|
svc, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
|
||||||
|
|
||||||
|
source, err := svc.OpenDM(ctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, source.ID)
|
||||||
|
target, err := svc.OpenDM(ctx, a, c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupConv(t, pg, target.ID)
|
||||||
|
|
||||||
|
// A quote with no body at all — the case that forwarded blank.
|
||||||
|
sent, err := svc.Send(ctx, a, source.ID, SendRequest{
|
||||||
|
Quote: &QuoteAttachment{
|
||||||
|
Path: "/books/bg/2/13?hl=abc",
|
||||||
|
Title: "Bhagavad-gītā 2.13",
|
||||||
|
Text: "As the embodied soul continually passes…",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sent.Body != "" {
|
||||||
|
t.Fatalf("setup: expected a body-less quote, got %q", sent.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fwd, err := svc.ForwardMessage(ctx, a, sent.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := svc.Messages(ctx, c, target.ID, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var quote *model.Attachment
|
||||||
|
for i := range msgs {
|
||||||
|
if msgs[i].ID != fwd.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := range msgs[i].Attachments {
|
||||||
|
if msgs[i].Attachments[j].Kind == model.AttachmentQuote {
|
||||||
|
quote = &msgs[i].Attachments[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote == nil {
|
||||||
|
t.Fatal("the forwarded message has no quote — it would render empty")
|
||||||
|
}
|
||||||
|
if quote.Description == nil || *quote.Description == "" {
|
||||||
|
t.Error("the forwarded quote lost its passage")
|
||||||
|
}
|
||||||
|
if quote.URL == nil || *quote.URL != "/books/bg/2/13?hl=abc" {
|
||||||
|
t.Errorf("the forwarded quote lost its deep link: %v", quote.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copy belongs to the DESTINATION thread: reads authorize against the
|
||||||
|
// attachment's own conversation, so a copy still scoped to the source
|
||||||
|
// would be unreadable to the recipient.
|
||||||
|
var convID string
|
||||||
|
if err := pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT conversation_id FROM message_attachments WHERE message_id = $1`,
|
||||||
|
fwd.ID).Scan(&convID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if convID != target.ID {
|
||||||
|
t.Errorf("copied attachment is scoped to %s, want the destination %s", convID, target.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The original is untouched.
|
||||||
|
back, err := svc.Messages(ctx, b, source.ID, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for i := range back {
|
||||||
|
if back[i].ID == sent.ID && len(back[i].Attachments) == 1 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("forwarding disturbed the original message's attachment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ var PrefCategories = map[string][]string{
|
|||||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||||
"community": {NotifDiscipleJoined},
|
"community": {NotifDiscipleJoined},
|
||||||
// Push only: in-app delivery for chat is the inbox itself, and there is
|
// Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
|
||||||
// no such thing as an emailed chat message.
|
// In-app delivery for chat is the inbox itself, so it isn't offered here.
|
||||||
"messages": {NotifChatMessage},
|
"messages": {NotifChatMessage},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
|||||||
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
||||||
s.push.SendToUser(ctx, authorID, PushPayload{
|
s.push.SendToUser(ctx, authorID, PushPayload{
|
||||||
Title: "New Like",
|
Title: "New Like",
|
||||||
Body: body,
|
Body: richtext.Plain(body),
|
||||||
URL: url,
|
URL: url,
|
||||||
Icon: s.iconURL(),
|
Icon: s.iconURL(),
|
||||||
Tag: NotifPostLike + ":" + postID,
|
Tag: NotifPostLike + ":" + postID,
|
||||||
@@ -642,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
|||||||
if s.rt != nil {
|
if s.rt != nil {
|
||||||
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
||||||
"kind": "admin_message",
|
"kind": "admin_message",
|
||||||
"body": body,
|
"body": richtext.Plain(body),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("realtime admin message publish failed",
|
s.logger.Warn("realtime admin message publish failed",
|
||||||
zap.String("recipient", recipientID), zap.Error(err))
|
zap.String("recipient", recipientID), zap.Error(err))
|
||||||
@@ -670,7 +671,9 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
|||||||
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||||
"RecipientName": recipient.Name,
|
"RecipientName": recipient.Name,
|
||||||
"Title": strings.TrimSpace(title),
|
"Title": strings.TrimSpace(title),
|
||||||
"Body": body,
|
// Rendered markers (bold, links…). Already escaped by richtext,
|
||||||
|
// and its <br>s replace the old white-space:pre-line.
|
||||||
|
"Body": richtext.ToHTML(body),
|
||||||
"URL": url,
|
"URL": url,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||||
@@ -882,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<di
|
|||||||
|
|
||||||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||||
|
|
||||||
<p style="white-space:pre-line">{{.Body}}</p>
|
<p>{{.Body}}</p>
|
||||||
|
|
||||||
<p style="margin:1.25em 0">
|
<p style="margin:1.25em 0">
|
||||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxQuizQuestions = 100
|
||||||
|
minQuizOptions = 2
|
||||||
|
maxQuizOptions = 5
|
||||||
|
maxQuizTitle = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrQuizTitle = errors.New("a quiz needs a title")
|
||||||
|
ErrQuizNoQuestions = errors.New("a quiz needs at least one question")
|
||||||
|
ErrQuizTooMany = fmt.Errorf("a quiz can have at most %d questions", maxQuizQuestions)
|
||||||
|
ErrQuizPrompt = errors.New("every question needs a prompt")
|
||||||
|
ErrQuizOptionCount = fmt.Errorf("each question needs %d to %d options", minQuizOptions, maxQuizOptions)
|
||||||
|
ErrQuizOptionBody = errors.New("every option needs text")
|
||||||
|
ErrQuizOneCorrect = errors.New("a single-answer question needs exactly one correct option")
|
||||||
|
ErrQuizNoCorrect = errors.New("a multiple-answer question needs at least one correct option")
|
||||||
|
ErrQuizLocked = errors.New("people have already taken this quiz, so its questions can't change")
|
||||||
|
ErrQuizTarget = errors.New("that link isn't a book chapter, lecture or conversation")
|
||||||
|
ErrQuizUnpublished = errors.New("this quiz isn't available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuizService is both sides of quizzes: authoring for staff, taking for users.
|
||||||
|
type QuizService struct {
|
||||||
|
quizzes *repository.QuizRepo
|
||||||
|
// content resolves a pasted link into a target title and kind.
|
||||||
|
content *repository.VaniRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuizService(quizzes *repository.QuizRepo, content *repository.VaniRepo) *QuizService {
|
||||||
|
return &QuizService{quizzes: quizzes, content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuizDraft is what the admin editor submits. TargetURL is pasted from the
|
||||||
|
// app; empty means a stand-alone quiz.
|
||||||
|
type QuizDraft struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
TargetURL string
|
||||||
|
// TargetTitle overrides the resolved content title when set.
|
||||||
|
TargetTitle string
|
||||||
|
Questions []repository.QuestionInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeContentPath reduces a pasted link to the path a content page
|
||||||
|
// reports for itself: no scheme, host, query, fragment or trailing slash.
|
||||||
|
// Both sides normalise identically, so "https://…/books/bg/2/?x" and the
|
||||||
|
// page's own "/books/bg/2" meet.
|
||||||
|
func NormalizeContentPath(raw string) string {
|
||||||
|
p := strings.TrimSpace(raw)
|
||||||
|
if u, err := url.Parse(p); err == nil && u.Host != "" {
|
||||||
|
p = u.Path
|
||||||
|
}
|
||||||
|
p = strings.SplitN(strings.SplitN(p, "?", 2)[0], "#", 2)[0]
|
||||||
|
p = "/" + strings.Trim(p, "/")
|
||||||
|
if p == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTarget works out what a link points at.
|
||||||
|
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, err error) {
|
||||||
|
p := NormalizeContentPath(raw)
|
||||||
|
if p == "" {
|
||||||
|
return model.QuizStandalone, nil, "", nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(parts) == 2 && parts[0] == "transcriptions":
|
||||||
|
t, err := s.content.TranscriptBySlug(ctx, parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", ErrQuizTarget
|
||||||
|
}
|
||||||
|
kind = model.QuizLecture
|
||||||
|
if t.Type == "conversation" {
|
||||||
|
kind = model.QuizConversation
|
||||||
|
}
|
||||||
|
title = t.Name
|
||||||
|
|
||||||
|
case len(parts) >= 3 && parts[0] == "books":
|
||||||
|
kind = model.QuizChapter
|
||||||
|
book := parts[1]
|
||||||
|
bookTitle := s.content.BookTitle(ctx, book)
|
||||||
|
if bookTitle == "" {
|
||||||
|
bookTitle = strings.ToUpper(book)
|
||||||
|
}
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if _, numErr := strconv.Atoi(last); numErr == nil {
|
||||||
|
// /books/bg/2, /books/sb/1/2, /books/cc/adi/3 — a numbered chapter.
|
||||||
|
title = bookTitle + " — Chapter " + last
|
||||||
|
} else if pr, err := s.content.ProseBySlug(ctx, book, last); err == nil {
|
||||||
|
title = pr.Title
|
||||||
|
} else {
|
||||||
|
title = bookTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", nil, "", ErrQuizTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
if o := strings.TrimSpace(override); o != "" {
|
||||||
|
title = o
|
||||||
|
}
|
||||||
|
return kind, &p, title, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
|
||||||
|
if len(qs) == 0 {
|
||||||
|
return nil, ErrQuizNoQuestions
|
||||||
|
}
|
||||||
|
if len(qs) > maxQuizQuestions {
|
||||||
|
return nil, ErrQuizTooMany
|
||||||
|
}
|
||||||
|
out := make([]repository.QuestionInput, 0, len(qs))
|
||||||
|
for _, q := range qs {
|
||||||
|
q.Prompt = strings.TrimSpace(q.Prompt)
|
||||||
|
q.Explanation = strings.TrimSpace(q.Explanation)
|
||||||
|
if q.Prompt == "" {
|
||||||
|
return nil, ErrQuizPrompt
|
||||||
|
}
|
||||||
|
if len(q.Options) < minQuizOptions || len(q.Options) > maxQuizOptions {
|
||||||
|
return nil, ErrQuizOptionCount
|
||||||
|
}
|
||||||
|
correct := 0
|
||||||
|
for i := range q.Options {
|
||||||
|
q.Options[i].Body = strings.TrimSpace(q.Options[i].Body)
|
||||||
|
if q.Options[i].Body == "" {
|
||||||
|
return nil, ErrQuizOptionBody
|
||||||
|
}
|
||||||
|
if q.Options[i].IsCorrect {
|
||||||
|
correct++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Multi && correct == 0 {
|
||||||
|
return nil, ErrQuizNoCorrect
|
||||||
|
}
|
||||||
|
if !q.Multi && correct != 1 {
|
||||||
|
return nil, ErrQuizOneCorrect
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) toInput(ctx context.Context, d QuizDraft, withQuestions bool) (repository.QuizInput, error) {
|
||||||
|
in := repository.QuizInput{
|
||||||
|
Title: strings.TrimSpace(d.Title),
|
||||||
|
Description: strings.TrimSpace(d.Description),
|
||||||
|
}
|
||||||
|
if in.Title == "" || len([]rune(in.Title)) > maxQuizTitle {
|
||||||
|
return in, ErrQuizTitle
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if in.TargetKind, in.TargetPath, in.TargetTitle, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
|
||||||
|
return in, err
|
||||||
|
}
|
||||||
|
if withQuestions {
|
||||||
|
if in.Questions, err = validateQuestions(d.Questions); err != nil {
|
||||||
|
return in, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *QuizService) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
|
||||||
|
return s.quizzes.ListAdmin(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) GetAdmin(ctx context.Context, id string) (*model.Quiz, error) {
|
||||||
|
q, err := s.quizzes.Get(ctx, id, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if q.AttemptCount, err = s.quizzes.AttemptCount(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (*model.Quiz, error) {
|
||||||
|
in, err := s.toInput(ctx, d, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := s.quizzes.Create(ctx, in, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetAdmin(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rewrites a quiz. Once anyone has attempted it, its questions are
|
||||||
|
// frozen — their answers reference those exact questions and options — and
|
||||||
|
// only the title, description and target may change. Sending questions then
|
||||||
|
// is refused rather than silently ignored.
|
||||||
|
func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*model.Quiz, error) {
|
||||||
|
attempts, err := s.quizzes.AttemptCount(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locked := attempts > 0
|
||||||
|
if locked && len(d.Questions) > 0 {
|
||||||
|
return nil, ErrQuizLocked
|
||||||
|
}
|
||||||
|
in, err := s.toInput(ctx, d, !locked)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetAdmin(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
|
||||||
|
if published {
|
||||||
|
q, err := s.quizzes.Get(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(q.Questions) == 0 {
|
||||||
|
return nil, ErrQuizNoQuestions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.quizzes.SetPublished(ctx, id, published); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetAdmin(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) Delete(ctx context.Context, id string) error {
|
||||||
|
return s.quizzes.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) Participants(ctx context.Context, id string) ([]model.QuizParticipant, error) {
|
||||||
|
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.quizzes.Participants(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) Responses(ctx context.Context, id string) ([]model.QuizQuestionStat, error) {
|
||||||
|
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.quizzes.QuestionStats(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParticipantResult is one person's answers, for the admin — including an
|
||||||
|
// attempt still in progress, which a user can never see of their own.
|
||||||
|
func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID string) (*model.QuizResult, error) {
|
||||||
|
return s.quizzes.Result(ctx, quizID, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Taking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// published loads a quiz that users may see.
|
||||||
|
func (s *QuizService) published(ctx context.Context, id string) (*model.Quiz, error) {
|
||||||
|
q, err := s.quizzes.Get(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if q.PublishedAt == nil {
|
||||||
|
return nil, ErrQuizUnpublished
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) withStatus(ctx context.Context, userID string, qs []model.Quiz) ([]model.QuizCard, error) {
|
||||||
|
ids := make([]string, len(qs))
|
||||||
|
for i, q := range qs {
|
||||||
|
ids[i] = q.ID
|
||||||
|
}
|
||||||
|
st, err := s.quizzes.Statuses(ctx, userID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]model.QuizCard, len(qs))
|
||||||
|
for i, q := range qs {
|
||||||
|
q.Questions = nil
|
||||||
|
my, ok := st[q.ID]
|
||||||
|
if !ok {
|
||||||
|
my = model.QuizStatus{State: model.QuizNotStarted, Total: q.QuestionCount}
|
||||||
|
}
|
||||||
|
out[i] = model.QuizCard{Quiz: q, My: my}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForPath returns the quiz on a content page with the caller's progress, or
|
||||||
|
// nil when the page has none. userID may be empty for signed-out readers.
|
||||||
|
func (s *QuizService) ForPath(ctx context.Context, userID, rawPath string) (*model.QuizCard, error) {
|
||||||
|
p := NormalizeContentPath(rawPath)
|
||||||
|
if p == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
q, err := s.quizzes.PublishedForPath(ctx, p)
|
||||||
|
if err != nil || q == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cards[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) ListPublished(ctx context.Context, userID string) ([]model.QuizCard, error) {
|
||||||
|
qs, err := s.quizzes.ListPublished(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.withStatus(ctx, userID, qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) Card(ctx context.Context, userID, id string) (*model.QuizCard, error) {
|
||||||
|
q, err := s.published(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cards[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start opens (or resumes) the caller's attempt and returns where they are.
|
||||||
|
func (s *QuizService) Start(ctx context.Context, userID, id string) (*model.QuizPlay, error) {
|
||||||
|
q, err := s.published(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.quizzes.Start(ctx, id, userID, len(q.Questions)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.next(ctx, userID, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuizService) next(ctx context.Context, userID string, q *model.Quiz) (*model.QuizPlay, error) {
|
||||||
|
play, err := s.quizzes.Next(ctx, q.ID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
play.Title = q.Title
|
||||||
|
return play, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer records a choice — one option, or several on a multi question — and
|
||||||
|
// returns the next step.
|
||||||
|
func (s *QuizService) Answer(ctx context.Context, userID, id, questionID string, optionIDs []string) (*model.QuizPlay, error) {
|
||||||
|
q, err := s.published(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.quizzes.Answer(ctx, id, userID, questionID, optionIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.next(ctx, userID, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the caller's OWN finished attempt — the only result a user can
|
||||||
|
// read. The user id comes from the session, never the request.
|
||||||
|
func (s *QuizService) Result(ctx context.Context, userID, id string) (*model.QuizResult, error) {
|
||||||
|
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.quizzes.Result(ctx, id, userID, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func q(prompt string, correct int, opts ...string) repository.QuestionInput {
|
||||||
|
out := repository.QuestionInput{Prompt: prompt}
|
||||||
|
for i, o := range opts {
|
||||||
|
out.Options = append(out.Options, repository.OptionInput{Body: o, IsCorrect: i == correct})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func withExplanation(x repository.QuestionInput, e string) repository.QuestionInput {
|
||||||
|
x.Explanation = e
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQuizSvc(t *testing.T) (*QuizService, *repository.Postgres) {
|
||||||
|
t.Helper()
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
// Stand-alone quizzes never consult content, so no Mongo is needed.
|
||||||
|
return NewQuizService(repository.NewQuizRepo(pg), nil), pg
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupQuiz(t *testing.T, pg *repository.Postgres, id string) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM quizzes WHERE id = $1`, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuiz_Validation(t *testing.T) {
|
||||||
|
svc, pg := newQuizSvc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := chatUser(t, pg, "Admin")
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
d QuizDraft
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
"no title": {QuizDraft{Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTitle},
|
||||||
|
"no questions": {QuizDraft{Title: "T"}, ErrQuizNoQuestions},
|
||||||
|
"one option": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a")}}, ErrQuizOptionCount},
|
||||||
|
"six options": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a", "b", "c", "d", "e", "f")}}, ErrQuizOptionCount},
|
||||||
|
"no correct": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", -1, "a", "b")}}, ErrQuizOneCorrect},
|
||||||
|
"blank prompt": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q(" ", 0, "a", "b")}}, ErrQuizPrompt},
|
||||||
|
"bad target url": {QuizDraft{Title: "T", TargetURL: "/letters/x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTarget},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
if _, err := svc.Create(ctx, admin, c.d); !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s: err = %v, want %v", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
two := q("p", 1, "a", "b")
|
||||||
|
two.Options[0].IsCorrect = true // two correct
|
||||||
|
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); !errors.Is(err, ErrQuizOneCorrect) {
|
||||||
|
t.Errorf("two correct: %v", err)
|
||||||
|
}
|
||||||
|
// …which is exactly what a multi question allows, but it needs one.
|
||||||
|
two.Multi = true
|
||||||
|
if quiz, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); err != nil {
|
||||||
|
t.Errorf("multi with two correct: %v", err)
|
||||||
|
} else {
|
||||||
|
cleanupQuiz(t, pg, quiz.ID)
|
||||||
|
}
|
||||||
|
none := q("p", -1, "a", "b")
|
||||||
|
none.Multi = true
|
||||||
|
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{none}}); !errors.Is(err, ErrQuizNoCorrect) {
|
||||||
|
t.Errorf("multi with none correct: %v, want ErrQuizNoCorrect", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A multi question is scored correct only on the exact set of correct
|
||||||
|
// options: a subset or an extra wrong pick both miss.
|
||||||
|
func TestQuiz_MultiChoice(t *testing.T) {
|
||||||
|
svc, pg := newQuizSvc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := chatUser(t, pg, "Admin")
|
||||||
|
|
||||||
|
multi := func(prompt string) repository.QuestionInput {
|
||||||
|
x := q(prompt, 0, "right A", "right B", "wrong")
|
||||||
|
x.Options[1].IsCorrect = true
|
||||||
|
x.Multi = true
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||||
|
Title: "Select all",
|
||||||
|
Questions: []repository.QuestionInput{multi("exact"), multi("subset"), multi("extra"), q("single", 0, "a", "b")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupQuiz(t, pg, quiz.ID)
|
||||||
|
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||||
|
if !full.Questions[0].Multi || full.Questions[3].Multi {
|
||||||
|
t.Fatal("multi flag didn't round-trip")
|
||||||
|
}
|
||||||
|
opt := func(qi, oi int) string { return full.Questions[qi].Options[oi].ID }
|
||||||
|
|
||||||
|
user := chatUser(t, pg, "U")
|
||||||
|
play, err := svc.Start(ctx, user, quiz.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !play.Question.Multi {
|
||||||
|
t.Error("the player isn't told the question takes several answers")
|
||||||
|
}
|
||||||
|
// Nothing chosen is refused.
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||||
|
t.Errorf("empty answer: %v, want ErrQuizChoiceCount", err)
|
||||||
|
}
|
||||||
|
steps := [][]string{
|
||||||
|
{opt(0, 1), opt(0, 0), opt(0, 0)}, // exact set, any order, duplicate ignored
|
||||||
|
{opt(1, 0)}, // subset
|
||||||
|
{opt(2, 0), opt(2, 1), opt(2, 2)}, // every option, one wrong
|
||||||
|
}
|
||||||
|
for _, ids := range steps {
|
||||||
|
if play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, ids); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A single-answer question still takes exactly one.
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0), opt(3, 1)}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||||
|
t.Errorf("two picks on a single question: %v, want ErrQuizChoiceCount", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.Result(ctx, user, quiz.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := []bool{}
|
||||||
|
for _, it := range res.Items {
|
||||||
|
got = append(got, it.IsCorrect)
|
||||||
|
}
|
||||||
|
if res.Score != 2 || !got[0] || got[1] || got[2] || !got[3] {
|
||||||
|
t.Errorf("score %d, per question %v; want 2, [true false false true]", res.Score, got)
|
||||||
|
}
|
||||||
|
if n := len(res.Items[0].ChosenOptionIDs); n != 2 {
|
||||||
|
t.Errorf("exact answer stored %d options, want 2 (duplicate dropped)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := svc.Responses(ctx, quiz.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Every option of the "extra" question was picked once.
|
||||||
|
for _, o := range stats[2].Options {
|
||||||
|
if o.Picks != 1 {
|
||||||
|
t.Errorf("option %q picks = %d, want 1", o.Body, o.Picks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuiz_TakingFlow(t *testing.T) {
|
||||||
|
svc, pg := newQuizSvc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, user, other := chatUser(t, pg, "Admin"), chatUser(t, pg, "U"), chatUser(t, pg, "O")
|
||||||
|
|
||||||
|
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||||
|
Title: "Bg basics",
|
||||||
|
Questions: []repository.QuestionInput{
|
||||||
|
withExplanation(q("Who spoke the *Gītā*?", 0, "Kṛṣṇa", "Arjuna", "Sañjaya"), "Kṛṣṇa, to Arjuna."),
|
||||||
|
q("Where?", 1, "Mathurā", "Kurukṣetra"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupQuiz(t, pg, quiz.ID)
|
||||||
|
|
||||||
|
// Drafts aren't reachable.
|
||||||
|
if _, err := svc.Start(ctx, user, quiz.ID); !errors.Is(err, ErrQuizUnpublished) {
|
||||||
|
t.Fatalf("starting a draft: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
play, err := svc.Start(ctx, user, quiz.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if play.Question == nil || play.Total != 2 || play.Answered != 0 {
|
||||||
|
t.Fatalf("first step = %+v", play)
|
||||||
|
}
|
||||||
|
// The correct answer never reaches someone mid-attempt — nor the
|
||||||
|
// explanation, which discusses it.
|
||||||
|
if play.Question.Explanation != "" {
|
||||||
|
t.Error("explanation leaked to a user mid-attempt")
|
||||||
|
}
|
||||||
|
for _, o := range play.Question.Options {
|
||||||
|
if o.IsCorrect != nil {
|
||||||
|
t.Fatal("is_correct leaked to a user mid-attempt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nor can they read a result yet.
|
||||||
|
if _, err := svc.Result(ctx, user, quiz.ID); !errors.Is(err, repository.ErrQuizNotFinished) {
|
||||||
|
t.Errorf("result mid-attempt: %v, want ErrQuizNotFinished", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
card, _ := svc.Card(ctx, user, quiz.ID)
|
||||||
|
if card.My.State != model.QuizInProgress {
|
||||||
|
t.Errorf("state after start = %s, want in_progress", card.My.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answering out of order (a question not yet served) is refused.
|
||||||
|
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||||
|
second := full.Questions[1]
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, second.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizNotCurrent) {
|
||||||
|
t.Errorf("answering ahead: %v, want ErrQuizNotCurrent", err)
|
||||||
|
}
|
||||||
|
// An option from another question is refused.
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizBadOption) {
|
||||||
|
t.Errorf("foreign option: %v, want ErrQuizBadOption", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q1 right, Q2 wrong.
|
||||||
|
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{full.Questions[0].Options[0].ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Can't change an answer.
|
||||||
|
if _, err := svc.Answer(ctx, user, quiz.ID, full.Questions[0].ID, []string{full.Questions[0].Options[1].ID}); !errors.Is(err, repository.ErrQuizAlreadyAnswer) {
|
||||||
|
t.Errorf("re-answering: %v, want ErrQuizAlreadyAnswer", err)
|
||||||
|
}
|
||||||
|
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !play.Done {
|
||||||
|
t.Fatalf("after the last answer: %+v", play)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.Result(ctx, user, quiz.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Score != 1 || res.Total != 2 || res.CompletedAt == nil {
|
||||||
|
t.Errorf("result = score %d/%d completed %v", res.Score, res.Total, res.CompletedAt)
|
||||||
|
}
|
||||||
|
for _, it := range res.Items {
|
||||||
|
if it.ResponseMs == nil {
|
||||||
|
t.Error("a response time wasn't recorded")
|
||||||
|
}
|
||||||
|
if len(it.ChosenOptionIDs) != 1 {
|
||||||
|
t.Error("the chosen option wasn't recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !res.Items[0].IsCorrect || res.Items[1].IsCorrect {
|
||||||
|
t.Error("per-question correctness is wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only your own result: someone who never took it has none.
|
||||||
|
if _, err := svc.Result(ctx, other, quiz.ID); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||||
|
t.Errorf("another user's result: %v, want ErrQuizNotStarted", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin sees the participant and the per-question picks.
|
||||||
|
parts, err := svc.Participants(ctx, quiz.ID)
|
||||||
|
if err != nil || len(parts) != 1 || parts[0].Score != 1 || parts[0].Answered != 2 {
|
||||||
|
t.Errorf("participants = %+v, %v", parts, err)
|
||||||
|
}
|
||||||
|
stats, err := svc.Responses(ctx, quiz.ID)
|
||||||
|
if err != nil || len(stats) != 2 || stats[0].Correct != 1 || stats[1].Correct != 0 {
|
||||||
|
t.Errorf("responses = %+v, %v", stats, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken, so questions are frozen — but the title can still change.
|
||||||
|
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
||||||
|
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
||||||
|
}
|
||||||
|
if upd, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "Bhagavad-gītā basics"}); err != nil || len(upd.Questions) != 2 {
|
||||||
|
t.Errorf("retitling a taken quiz: %v (questions %d)", err, len(upd.Questions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeContentPath(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"https://prabhupadasays.org/books/bg/2/?x=1#v3": "/books/bg/2",
|
||||||
|
"/transcriptions/720720sb-london": "/transcriptions/720720sb-london",
|
||||||
|
"books/bg/2": "/books/bg/2",
|
||||||
|
"": "",
|
||||||
|
"/": "",
|
||||||
|
} {
|
||||||
|
if got := NormalizeContentPath(in); got != want {
|
||||||
|
t.Errorf("NormalizeContentPath(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
crand "crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
@@ -64,6 +69,9 @@ type SadhnaService struct {
|
|||||||
posts *repository.PostRepo
|
posts *repository.PostRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
rewards *repository.RewardRepo
|
rewards *repository.RewardRepo
|
||||||
|
// r2 stores custom gift artwork. Optional: nil disables custom gifts
|
||||||
|
// rather than failing anything that already works.
|
||||||
|
r2 *repository.R2
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +80,13 @@ func NewSadhnaService(
|
|||||||
posts *repository.PostRepo,
|
posts *repository.PostRepo,
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
rewards *repository.RewardRepo,
|
rewards *repository.RewardRepo,
|
||||||
|
r2 *repository.R2,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *SadhnaService {
|
) *SadhnaService {
|
||||||
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
|
return &SadhnaService{
|
||||||
|
logs: logs, posts: posts, users: users, rewards: rewards,
|
||||||
|
r2: r2, logger: logger,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayFmt = "2006-01-02"
|
const dayFmt = "2006-01-02"
|
||||||
@@ -398,6 +410,87 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad
|
|||||||
|
|
||||||
// AckRewards clears the user's pending celebration(s) after the client has
|
// AckRewards clears the user's pending celebration(s) after the client has
|
||||||
// shown the confetti.
|
// shown the confetti.
|
||||||
|
// OpenGift un-blurs one gift after the devotee has seen its celebration.
|
||||||
|
//
|
||||||
|
// Per gift, not per user: someone with two waiting should open them one at a
|
||||||
|
// time rather than have the first dismissal clear both.
|
||||||
|
func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error {
|
||||||
|
return s.rewards.OpenGift(ctx, userID, rewardKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiftToEveryone hands a reward to every account. Returns how many it reached.
|
||||||
|
func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) {
|
||||||
|
reward, err := s.rewards.ByKey(ctx, rewardKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// customKeyPrefix namespaces admin-made rewards away from the seeded catalog,
|
||||||
|
// so Seed can never collide with one.
|
||||||
|
const customKeyPrefix = "custom_"
|
||||||
|
|
||||||
|
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
// customRewardKey derives a stable, unique key from a name.
|
||||||
|
//
|
||||||
|
// The random suffix is what makes it unique: two gifts may reasonably share a
|
||||||
|
// name, and a collision would overwrite the first one's artwork.
|
||||||
|
func customRewardKey(name string) (string, error) {
|
||||||
|
slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||||
|
if slug == "" {
|
||||||
|
slug = "gift"
|
||||||
|
}
|
||||||
|
if len(slug) > 32 {
|
||||||
|
slug = slug[:32]
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 4)
|
||||||
|
if _, err := crand.Read(suffix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomGift adds a new sticker to the catalog. It is not granted to
|
||||||
|
// anyone until it is sent.
|
||||||
|
func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("a gift needs a name")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(imageURL) == "" {
|
||||||
|
return nil, errors.New("a gift needs an image")
|
||||||
|
}
|
||||||
|
key, err := customRewardKey(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGiftImage issues an upload URL for custom sticker artwork.
|
||||||
|
//
|
||||||
|
// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an
|
||||||
|
// absolute address, and stickers render for every participant in a chat, so
|
||||||
|
// the artwork belongs in the public bucket alongside avatars and post images.
|
||||||
|
func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCustomGift removes a custom sticker everywhere it is held.
|
||||||
|
func (s *SadhnaService) DeleteCustomGift(ctx context.Context, key string) error {
|
||||||
|
if !strings.HasPrefix(key, customKeyPrefix) {
|
||||||
|
return errors.New("only custom gifts can be deleted")
|
||||||
|
}
|
||||||
|
return s.rewards.DeleteCustom(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiftableRewards is the catalog an admin picks from.
|
||||||
|
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
|
||||||
|
return s.rewards.ListGiftable(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
||||||
return s.rewards.MarkCelebrated(ctx, userID)
|
return s.rewards.MarkCelebrated(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp
|
|||||||
// presignImage builds an R2 presign for a user-scoped media key under the
|
// presignImage builds an R2 presign for a user-scoped media key under the
|
||||||
// given prefix. The random suffix invalidates browser caches when the image
|
// given prefix. The random suffix invalidates browser caches when the image
|
||||||
// is replaced.
|
// is replaced.
|
||||||
|
// presignImageKey is presignImage's sibling for callers that store the storage
|
||||||
|
// KEY rather than a URL — so the CDN domain can change without rewriting rows.
|
||||||
|
// Handing those callers a public URL silently doubles it at read time.
|
||||||
|
func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) {
|
||||||
|
if r2 == nil {
|
||||||
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
}
|
||||||
|
ext, ok := imageExtensionFor(contentType)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(suffix); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext)
|
||||||
|
uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return uploadURL, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
if r2 == nil {
|
if r2 == nil {
|
||||||
return "", "", errors.New("media uploads are not configured")
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
|
||||||
|
// isn't configured (local development leaves it empty).
|
||||||
|
const defaultAdminURL = "https://admin.prabhupadasays.org"
|
||||||
|
|
||||||
|
// sendStaffWelcome tells someone they've been given admin access, and as
|
||||||
|
// what. Fire-and-forget, like the other notification emails: the grant is
|
||||||
|
// already committed, so a mail failure is logged, never surfaced.
|
||||||
|
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
|
||||||
|
if s.mail == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
|
||||||
|
s.logger.Info("staff granted; no email on file, skipping welcome",
|
||||||
|
zap.String("user_id", m.UserID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": strings.TrimSpace(m.Name),
|
||||||
|
"RoleName": m.RoleName,
|
||||||
|
"AdminURL": s.adminURL,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render staff welcome email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to := strings.TrimSpace(*m.Email)
|
||||||
|
go func() {
|
||||||
|
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
|
||||||
|
s.logger.Warn("staff welcome email send failed",
|
||||||
|
zap.String("user_id", m.UserID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// staffWelcomeEmailTpl — same styling as the other account emails. The role
|
||||||
|
// name is admin-entered (custom roles) and auto-escaped by html/template.
|
||||||
|
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
|
||||||
|
|
||||||
|
<p>Log in with your email and password at
|
||||||
|
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>For any queries, contact your co-ordinator.</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrStaffSelf = errors.New("you can't change your own staff access")
|
||||||
|
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
|
||||||
|
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
|
||||||
|
ErrRoleBadPermission = errors.New("unknown permission")
|
||||||
|
ErrRoleName = errors.New("a role needs a name")
|
||||||
|
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaffService manages who can use the admin panel and with what powers.
|
||||||
|
// Every method here is reachable only with rbac.ManageStaff — superadmins.
|
||||||
|
type StaffService struct {
|
||||||
|
staff *repository.StaffRepo
|
||||||
|
// mail tells newly added staff about their access; nil sends nothing.
|
||||||
|
mail mailer.Mailer
|
||||||
|
logger *zap.Logger
|
||||||
|
adminURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStaffService wires staff management. adminURL is the panel's address for
|
||||||
|
// the welcome email; empty falls back to the production panel.
|
||||||
|
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
|
||||||
|
if adminURL == "" {
|
||||||
|
adminURL = defaultAdminURL
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me describes the caller, for the panel to decide what to show.
|
||||||
|
type Me struct {
|
||||||
|
RoleKey string `json:"role_key"`
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
|
||||||
|
return s.staff.ListStaff(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSuperadminRole reports whether a role id is the seeded superadmin.
|
||||||
|
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
|
||||||
|
ro, err := s.staff.Role(ctx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return ro.Key == repository.SuperadminKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
|
||||||
|
// out of that state if they're the last one — demoting, revoking or blocking
|
||||||
|
// them would leave no one able to manage staff, with no way back from the UI.
|
||||||
|
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
|
||||||
|
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := s.staff.ActiveSuperadmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return ErrLastSuperadmin
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant gives an account a role, by handle.
|
||||||
|
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
|
||||||
|
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if userID == actorID {
|
||||||
|
return nil, ErrStaffSelf
|
||||||
|
}
|
||||||
|
if _, err := s.staff.Role(ctx, roleID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Granting to someone who is already staff is a role change, and must
|
||||||
|
// pass the same last-superadmin check.
|
||||||
|
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
|
||||||
|
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
|
||||||
|
}
|
||||||
|
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m, err := s.staff.StaffMember(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Only a NEW staff member is welcomed; role changes above stay quiet.
|
||||||
|
s.sendStaffWelcome(m)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeRole moves a staff member to another role.
|
||||||
|
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
|
||||||
|
if targetID == actorID {
|
||||||
|
return nil, ErrStaffSelf
|
||||||
|
}
|
||||||
|
target, err := s.staff.StaffMember(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
toSuper, err := s.isSuperadminRole(ctx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !toSuper {
|
||||||
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.staff.StaffMember(ctx, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke removes a staff member's access entirely.
|
||||||
|
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
|
||||||
|
if targetID == actorID {
|
||||||
|
return ErrStaffSelf
|
||||||
|
}
|
||||||
|
target, err := s.staff.StaffMember(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.staff.SetRole(ctx, targetID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlocked suspends or restores a staff member's panel access, keeping their
|
||||||
|
// role so restoring them is one action rather than a re-grant.
|
||||||
|
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
|
||||||
|
if targetID == actorID {
|
||||||
|
return nil, ErrStaffSelf
|
||||||
|
}
|
||||||
|
target, err := s.staff.StaffMember(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.staff.StaffMember(ctx, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
|
||||||
|
return s.staff.ListRoles(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanPermissions validates a custom role's permissions against the catalog.
|
||||||
|
// The wildcard and admins:manage aren't in the catalog, so they are refused
|
||||||
|
// here — a custom role can never become a second superadmin.
|
||||||
|
func cleanPermissions(perms []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range perms {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !rbac.Valid(p) {
|
||||||
|
return nil, ErrRoleBadPermission
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, ErrRoleNoPermissions
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func roleKeyFor(name string) string {
|
||||||
|
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||||
|
if len(k) > 40 {
|
||||||
|
k = k[:40]
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
key := roleKeyFor(name)
|
||||||
|
if name == "" || key == "" {
|
||||||
|
return nil, ErrRoleName
|
||||||
|
}
|
||||||
|
clean, err := cleanPermissions(perms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
|
||||||
|
ro, err := s.staff.Role(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ro.IsSystem {
|
||||||
|
return nil, ErrRoleSystem
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, ErrRoleName
|
||||||
|
}
|
||||||
|
clean, err := cleanPermissions(perms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.staff.Role(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
|
||||||
|
ro, err := s.staff.Role(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ro.IsSystem {
|
||||||
|
return ErrRoleSystem
|
||||||
|
}
|
||||||
|
return s.staff.DeleteRole(ctx, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
if err := pg.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("role %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := chatUser(t, pg, roleKey)
|
||||||
|
if _, err := pg.Pool.Exec(context.Background(),
|
||||||
|
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The floor: removing the last active superadmin would leave nobody able to
|
||||||
|
// manage staff, with no way back from the UI.
|
||||||
|
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
staff := repository.NewStaffRepo(pg)
|
||||||
|
svc := NewStaffService(staff, nil, nil, "")
|
||||||
|
|
||||||
|
// Isolate from any staff already in the dev database: the floor counts
|
||||||
|
// superadmins globally. Parked in a REAL table — a TEMP table is
|
||||||
|
// per-connection, and the pool may run the cleanup on a different one,
|
||||||
|
// which would leave real staff stripped of their roles.
|
||||||
|
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
|
||||||
|
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
|
||||||
|
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
|
||||||
|
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
|
||||||
|
t.Errorf("restoring parked staff: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
|
||||||
|
})
|
||||||
|
|
||||||
|
a := makeStaff(t, pg, "superadmin")
|
||||||
|
b := makeStaff(t, pg, "superadmin")
|
||||||
|
mod := makeStaff(t, pg, "moderator")
|
||||||
|
|
||||||
|
// Nobody acts on themselves.
|
||||||
|
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
|
||||||
|
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
|
||||||
|
t.Errorf("self-block: %v, want ErrStaffSelf", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A superadmin can block another admin/mod — and another superadmin while
|
||||||
|
// one remains.
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
|
||||||
|
t.Errorf("block moderator: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
|
||||||
|
t.Errorf("block second superadmin: %v", err)
|
||||||
|
}
|
||||||
|
// Now a is the only active one: a blocked b can't be used to demote a.
|
||||||
|
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
|
||||||
|
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||||
|
}
|
||||||
|
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
|
||||||
|
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blocked staff member is turned away at the door.
|
||||||
|
acc, err := staff.Access(ctx, mod)
|
||||||
|
if err != nil || !acc.Blocked {
|
||||||
|
t.Errorf("blocked moderator access = %+v, %v", acc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom role is built only from the catalog — never the wildcard or
|
||||||
|
// admins:manage, which would make it a second superadmin.
|
||||||
|
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
|
||||||
|
|
||||||
|
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
|
||||||
|
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
|
||||||
|
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
|
||||||
|
|
||||||
|
set := rbac.NewSet(ro.Permissions)
|
||||||
|
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
|
||||||
|
t.Errorf("custom role permissions resolved wrong: %v", set.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// System roles are read-only.
|
||||||
|
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
|
||||||
|
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureMailer records sends; the welcome email goes out on a goroutine.
|
||||||
|
type captureMailer struct{ sent chan [3]string }
|
||||||
|
|
||||||
|
func (m *captureMailer) Send(to, subject, body string) error {
|
||||||
|
m.sent <- [3]string{to, subject, body}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding someone as staff emails them their role and where to log in; moving
|
||||||
|
// an existing staff member to another role does not.
|
||||||
|
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
|
||||||
|
_, _, pg := testChatService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mail := &captureMailer{sent: make(chan [3]string, 4)}
|
||||||
|
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
|
||||||
|
|
||||||
|
actor := chatUser(t, pg, "Actor")
|
||||||
|
target, handle := chatUserWithHandle(t, pg, "Target")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-mail.sent:
|
||||||
|
if !strings.HasPrefix(got[0], "chatsvc-") {
|
||||||
|
t.Errorf("sent to %q, want the target's address", got[0])
|
||||||
|
}
|
||||||
|
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
|
||||||
|
if !strings.Contains(got[2], want) {
|
||||||
|
t.Errorf("email body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[1], "Moderator") {
|
||||||
|
t.Errorf("subject %q doesn't name the role", got[1])
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("no welcome email was sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-mail.sent:
|
||||||
|
t.Errorf("a role change sent an email: %q", got[1])
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +34,13 @@ func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService
|
|||||||
// Today returns the current day's pick, choosing (and persisting) one on the
|
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||||
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||||
// content to pick.
|
// content to pick.
|
||||||
|
var (
|
||||||
|
// ErrVaniBadKind is a kind outside verse/prose/transcript.
|
||||||
|
ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
|
||||||
|
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
|
||||||
|
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
|
||||||
|
)
|
||||||
|
|
||||||
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
@@ -75,6 +85,136 @@ func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
|||||||
|
|
||||||
// choose tries the three content kinds in random order and returns the first
|
// choose tries the three content kinds in random order and returns the first
|
||||||
// successful sample, so each day's pick varies across content types.
|
// successful sample, so each day's pick varies across content types.
|
||||||
|
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
|
||||||
|
//
|
||||||
|
// The intended admin workflow: paste the link to the thing you want. Search
|
||||||
|
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
|
||||||
|
// matches on "london" and returns unrelated lectures.
|
||||||
|
//
|
||||||
|
// Accepts an absolute URL or a path, and understands the three shapes the
|
||||||
|
// vāṇī can point at:
|
||||||
|
//
|
||||||
|
// /transcriptions/{slug}
|
||||||
|
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
|
||||||
|
// /books/{book}/{slug} (prose chapter)
|
||||||
|
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||||
|
day = day.UTC().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
if u, err := url.Parse(path); err == nil && u.Host != "" {
|
||||||
|
path = u.Path
|
||||||
|
}
|
||||||
|
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, ErrVaniBadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parts[0] {
|
||||||
|
case "transcriptions":
|
||||||
|
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return transcriptPick(t, day), nil
|
||||||
|
|
||||||
|
case "books":
|
||||||
|
book := parts[1]
|
||||||
|
nums := parts[2:]
|
||||||
|
// A trailing division segment (SB cantos, CC līlās) shifts the
|
||||||
|
// chapter/verse pair along by one.
|
||||||
|
var division *string
|
||||||
|
if len(nums) == 3 {
|
||||||
|
d := nums[0]
|
||||||
|
division, nums = &d, nums[1:]
|
||||||
|
}
|
||||||
|
if len(nums) == 2 {
|
||||||
|
chapter, err1 := strconv.Atoi(nums[0])
|
||||||
|
verse, err2 := strconv.Atoi(nums[1])
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return versePick(v, day), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not numeric, so it is a prose chapter's bare slug.
|
||||||
|
if len(nums) == 1 {
|
||||||
|
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrVaniBadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByURL resolves a URL and pins the result.
|
||||||
|
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||||
|
pick, err := s.ResolveURL(ctx, raw, day)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pick, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetForDay pins a specific piece of content as a given day's Vāṇī.
|
||||||
|
//
|
||||||
|
// Takes only a kind and a content id — never a payload. The display blob is
|
||||||
|
// rebuilt here with the same builders the random picker uses, so a pinned pick
|
||||||
|
// is indistinguishable from an automatic one and a client cannot dictate what
|
||||||
|
// the home page says.
|
||||||
|
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
|
||||||
|
day = day.UTC().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
var pick *model.VaniPick
|
||||||
|
switch kind {
|
||||||
|
case "verse":
|
||||||
|
v, err := s.repo.VerseByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = versePick(v, day)
|
||||||
|
case "prose":
|
||||||
|
p, err := s.repo.ProseByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
||||||
|
case "transcript":
|
||||||
|
t, err := s.repo.TranscriptByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = transcriptPick(t, day)
|
||||||
|
default:
|
||||||
|
return nil, ErrVaniBadKind
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pick, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearForDay drops a pinned pick, handing the day back to the random picker.
|
||||||
|
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
|
||||||
|
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule lists decided days from today onward, for the admin view.
|
||||||
|
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
|
||||||
|
if limit <= 0 || limit > 60 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
||||||
kinds := []string{"verse", "prose", "transcript"}
|
kinds := []string{"verse", "prose", "transcript"}
|
||||||
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS message_reactions;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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'));
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Restore the old flag for anyone who still has a role, so a rollback keeps
|
||||||
|
-- them in the panel.
|
||||||
|
UPDATE users SET role = 'admin' WHERE admin_role_id IS NOT NULL AND admin_blocked_at IS NULL;
|
||||||
|
DROP INDEX IF EXISTS users_admin_role_idx;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS admin_blocked_at;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS admin_role_id;
|
||||||
|
DROP TABLE IF EXISTS admin_roles;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Role-based access for the admin panel.
|
||||||
|
--
|
||||||
|
-- Until now admin access was one flag: users.role = 'admin'. This replaces it
|
||||||
|
-- with named roles carrying explicit permissions, so a moderator can resolve
|
||||||
|
-- reports without also being able to ban users or gift stickers to everyone.
|
||||||
|
--
|
||||||
|
-- Three system roles are seeded and cannot be edited or deleted; superadmins
|
||||||
|
-- can define further custom roles from any subset of the permission catalog
|
||||||
|
-- (which lives in code — see internal/rbac). The one thing a custom role can
|
||||||
|
-- never hold is admins:manage: granting, changing, revoking and blocking staff
|
||||||
|
-- stays with superadmins.
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
-- System roles are seeded below and are read-only.
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- "resource:action" strings, or '*' for everything (superadmin only).
|
||||||
|
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO admin_roles (key, name, description, is_system, permissions) VALUES
|
||||||
|
('superadmin', 'Superadmin',
|
||||||
|
'Everything, including managing other staff.', TRUE, ARRAY['*']),
|
||||||
|
('admin', 'Admin',
|
||||||
|
'Runs the platform day to day. Cannot manage staff.', TRUE, ARRAY[
|
||||||
|
'stats:read','users:read','users:notify','users:ban','users:limits',
|
||||||
|
'disciples:manage','posts:moderate','comments:moderate','collections:read',
|
||||||
|
'feedback:read','feedback:reply','feedback:resolve',
|
||||||
|
'reports:read','reports:resolve','vani:read','vani:write',
|
||||||
|
'rewards:read','rewards:write','rewards:send','rewards:delete',
|
||||||
|
'broadcasts:read','broadcasts:write','broadcasts:publish',
|
||||||
|
'short_urls:read','quizzes:read','quizzes:write','quizzes:delete','quizzes:results']),
|
||||||
|
('moderator', 'Moderator',
|
||||||
|
'Keeps the community healthy: reports, moderation and feedback.', TRUE, ARRAY[
|
||||||
|
'stats:read','users:read','posts:moderate','comments:moderate',
|
||||||
|
'collections:read','feedback:read','feedback:reply','feedback:resolve',
|
||||||
|
'reports:read','reports:resolve'])
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Staff membership. NULL = not staff.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS admin_role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- A superadmin can suspend another staff member's panel access without taking
|
||||||
|
-- their role away, so restoring them is one click rather than a re-grant.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_blocked_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS users_admin_role_idx
|
||||||
|
ON users (admin_role_id) WHERE admin_role_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Everyone who was an admin under the old single flag becomes a superadmin:
|
||||||
|
-- they had unrestricted access before, so anything less would lock existing
|
||||||
|
-- staff out of the panel the moment this deploys.
|
||||||
|
UPDATE users
|
||||||
|
SET admin_role_id = (SELECT id FROM admin_roles WHERE key = 'superadmin')
|
||||||
|
WHERE role = 'admin' AND admin_role_id IS NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS quiz_answers;
|
||||||
|
DROP TABLE IF EXISTS quiz_attempts;
|
||||||
|
DROP TABLE IF EXISTS quiz_options;
|
||||||
|
DROP TABLE IF EXISTS quiz_questions;
|
||||||
|
DROP TABLE IF EXISTS quizzes;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Quizzes on the library's content.
|
||||||
|
--
|
||||||
|
-- A quiz is attached to a piece of content by its app path — a book chapter
|
||||||
|
-- (/books/bg/2), or a lecture or conversation (/transcriptions/{slug}) — or to
|
||||||
|
-- nothing at all, for a stand-alone quiz. The path is what the content page
|
||||||
|
-- already knows about itself, so it can ask "is there a quiz for me?" without
|
||||||
|
-- the content's Mongo id ever reaching Postgres.
|
||||||
|
CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
-- Light formatting markers, rendered like the rest of the admin text.
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
-- chapter | lecture | conversation | standalone
|
||||||
|
target_kind VARCHAR(16) NOT NULL DEFAULT 'standalone',
|
||||||
|
-- App path of the content, NULL for a stand-alone quiz.
|
||||||
|
target_path TEXT,
|
||||||
|
-- Display name of the content, captured when the quiz is made.
|
||||||
|
target_title TEXT NOT NULL DEFAULT '',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
-- Soft delete: participants' results stay intact for the record.
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT quizzes_target_kind_check
|
||||||
|
CHECK (target_kind IN ('chapter', 'lecture', 'conversation', 'standalone')),
|
||||||
|
CONSTRAINT quizzes_target_consistent
|
||||||
|
CHECK ((target_kind = 'standalone') = (target_path IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Is there a quiz for this page?" — asked by every content page load.
|
||||||
|
CREATE INDEX IF NOT EXISTS quizzes_target_idx
|
||||||
|
ON quizzes (target_path, published_at DESC)
|
||||||
|
WHERE deleted_at IS NULL AND published_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
|
||||||
|
ord SMALLINT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
-- Optional note shown with the answer on the results screen.
|
||||||
|
explanation TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (quiz_id, ord)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_options (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
|
||||||
|
ord SMALLINT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
UNIQUE (question_id, ord),
|
||||||
|
-- Five options at most; the service also enforces two at least and
|
||||||
|
-- exactly one correct.
|
||||||
|
CONSTRAINT quiz_options_ord_check CHECK (ord BETWEEN 0 AND 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One attempt per person per quiz: it is what "Continue" resumes and what
|
||||||
|
-- "View results" shows.
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_attempts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (quiz_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS quiz_attempts_user_idx ON quiz_attempts (user_id);
|
||||||
|
|
||||||
|
-- One row per question an attempt has reached.
|
||||||
|
--
|
||||||
|
-- Timing is measured by the SERVER: shown_at is stamped when the question is
|
||||||
|
-- handed to the client, answered_at when the answer arrives. A client-reported
|
||||||
|
-- duration could be anything; these two timestamps can't be edited from the
|
||||||
|
-- browser.
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_answers (
|
||||||
|
attempt_id UUID NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
|
||||||
|
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
|
||||||
|
option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL,
|
||||||
|
shown_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
answered_at TIMESTAMPTZ,
|
||||||
|
response_ms INTEGER,
|
||||||
|
is_correct BOOLEAN,
|
||||||
|
PRIMARY KEY (attempt_id, question_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL;
|
||||||
|
-- Lossy for multi answers: keeps the first chosen option only.
|
||||||
|
UPDATE quiz_answers SET option_id = option_ids[1] WHERE option_ids IS NOT NULL;
|
||||||
|
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_ids;
|
||||||
|
ALTER TABLE quiz_questions DROP COLUMN IF EXISTS multi;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Multiple-choice questions with more than one correct answer.
|
||||||
|
--
|
||||||
|
-- A question marked multi asks the reader to "select all that apply"; it is
|
||||||
|
-- scored correct only when the chosen set equals the correct set exactly. A
|
||||||
|
-- single-answer question keeps exactly one correct option.
|
||||||
|
ALTER TABLE quiz_questions ADD COLUMN IF NOT EXISTS multi BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- An answer is now a set of options. Arrays can't carry a foreign key; that
|
||||||
|
-- is safe here because a quiz's options are frozen once anyone has answered,
|
||||||
|
-- and options only ever go away with their whole question.
|
||||||
|
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_ids UUID[];
|
||||||
|
UPDATE quiz_answers SET option_ids = ARRAY[option_id]
|
||||||
|
WHERE option_id IS NOT NULL AND option_ids IS NULL;
|
||||||
|
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_id;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user