16 Commits
Author SHA1 Message Date
Krispire Admin 4b38047c26 feat: quiz and rbac 2026-10-15 19:43:17 +05:30
Krispire Admin 7d7ba2a002 feedback formatter 2026-10-09 06:40:13 +05:30
Krispire Admin 6633d0cca3 forward chat msg fix 2026-09-02 23:00:55 +05:30
Krispire Admin 0fee56d272 feat: vani of the day edit 2026-08-31 13:39:56 +05:30
Krispire Admin 44fd1d69ab fix: hide suspended account 2026-08-31 12:28:25 +05:30
Krispire Admin ac92bc2cf3 feat: chat notification 2026-08-30 11:13:33 +05:30
Krispire Admin a2d22ed829 feat: add stickers delete api 2026-08-30 10:31:37 +05:30
Krispire Admin 4427849647 feat: custom stickers apis 2026-08-30 03:02:49 +05:30
Krispire Admin fecb9bc045 feat: add stickers 2026-08-30 02:18:53 +05:30
Krispire Admin cee2d3c874 feat: chat reactions 2026-08-30 01:51:32 +05:30
Krispire Admin 9c458ba19f fix: no attachment broadcast fix 2026-08-29 18:56:33 +05:30
Krispire Admin f65f552c87 feat: broadcast 2026-08-29 18:34:07 +05:30
Krispire Admin 44120efdec fix: chat UI 2026-08-29 17:30:14 +05:30
Krispire Admin 7a730cd42e fix: chat accepts posts 2026-08-29 16:26:38 +05:30
Krispire Admin 18bac9a22d fix: chat accepts quotes now 2026-08-29 11:58:20 +05:30
Krispire Admin d50031cdb3 feat: add chat 2026-08-29 03:49:30 +05:30
81 changed files with 14990 additions and 110 deletions
+2
View File
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
# Frontend (CORS + email link base) # Frontend (CORS + email link base)
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
ADMIN_URL=
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=
+26 -3
View File
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
@@ -15,9 +17,15 @@ import (
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in // AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
// the router, so every method here can assume an admin caller. // the router, so every method here can assume an admin caller.
type AdminHandler struct{ svc *service.AdminService } type AdminHandler struct {
svc *service.AdminService
// staff guards actions that would otherwise reach other staff members.
staff *repository.StaffRepo
}
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} } func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
return &AdminHandler{svc: svc, staff: staff}
}
// pageParams pulls (q, limit, offset) from the query string with sane defaults. // pageParams pulls (q, limit, offset) from the query string with sane defaults.
func pageParams(r *http.Request) (q string, limit, offset int) { func pageParams(r *http.Request) (q string, limit, offset int) {
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
} }
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) { func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned) target := chi.URLParam(r, "id")
// A banned account loses panel access, so banning a staff member is a way
// to remove them — which only superadmins may do. Without this, an Admin
// holding users:ban could ban a Superadmin out of the panel.
if _, err := h.staff.Access(r.Context(), target); err == nil {
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
response.Error(w, http.StatusForbidden,
"staff accounts are managed from the Staff page by a superadmin")
return
}
if target == middleware.UserID(r.Context()) {
response.Error(w, http.StatusConflict, "you can't ban yourself")
return
}
}
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
if err != nil { if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update account") response.Error(w, http.StatusInternalServerError, "could not update account")
return return
+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// BroadcastHandler serves both sides of announcements: the admin routes that
// compose and publish them, and the two app routes that fetch and dismiss.
type BroadcastHandler struct {
svc *service.BroadcastService
}
func NewBroadcastHandler(svc *service.BroadcastService) *BroadcastHandler {
return &BroadcastHandler{svc: svc}
}
func writeBroadcastError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrBroadcastGone):
response.Error(w, http.StatusNotFound, "broadcast not found")
case errors.Is(err, service.ErrBroadcastTitle),
errors.Is(err, service.ErrBroadcastBody),
errors.Is(err, service.ErrBroadcastCTA),
errors.Is(err, service.ErrBroadcastBanner):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "broadcast request failed")
}
}
// ── App routes ────────────────────────────────────────────────────────────
// Pending — GET /broadcasts/pending. Returns null when nothing is waiting, so
// the client can call it on load and after a nudge without special-casing.
func (h *BroadcastHandler) Pending(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Pending(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// Seen — POST /broadcasts/{id}/seen. The "at most once" half; idempotent.
func (h *BroadcastHandler) Seen(w http.ResponseWriter, r *http.Request) {
if err := h.svc.MarkSeen(r.Context(), chi.URLParam(r, "id"),
middleware.UserID(r.Context())); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// ── Admin routes ──────────────────────────────────────────────────────────
type createBroadcastReq struct {
Title string `json:"title"`
Body string `json:"body"`
BannerKey *string `json:"banner_key"`
CTALabel *string `json:"cta_label"`
CTAURL *string `json:"cta_url"`
// ExpiresAt stops the announcement being offered. Without one, someone
// signing up months later would still be shown it.
ExpiresAt *time.Time `json:"expires_at"`
// Publish immediately rather than saving a draft.
Publish bool `json:"publish"`
}
// Create — POST /admin/broadcasts
func (h *BroadcastHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createBroadcastReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
b, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), service.CreateParams{
Title: req.Title, Body: req.Body, BannerKey: req.BannerKey,
CTALabel: req.CTALabel, CTAURL: req.CTAURL, ExpiresAt: req.ExpiresAt,
})
if err != nil {
writeBroadcastError(w, err)
return
}
if req.Publish {
published, err := h.svc.Publish(r.Context(), b.ID)
if err != nil {
writeBroadcastError(w, err)
return
}
b = published
}
response.JSON(w, http.StatusCreated, b)
}
// Publish — POST /admin/broadcasts/{id}/publish
func (h *BroadcastHandler) Publish(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Publish(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// List — GET /admin/broadcasts
func (h *BroadcastHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.svc.List(r.Context(), limit)
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Delete — DELETE /admin/broadcasts/{id}
func (h *BroadcastHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// PresignBanner — POST /admin/broadcasts/banner-presign
func (h *BroadcastHandler) PresignBanner(w http.ResponseWriter, r *http.Request) {
var req struct {
ContentType string `json:"content_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignBanner(r.Context(), strings.TrimSpace(req.ContentType))
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
+16
View File
@@ -55,6 +55,22 @@ func NotificationsRule() ChannelRule {
} }
} }
// ConversationRule allows a caller to subscribe to a thread's channel only
// while they are a participant. isParticipant is injected rather than taking a
// repository dependency here, so the authorization check stays the service's
// single source of truth.
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
return ChannelRule{
Prefix: "private-conversation.",
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
if suffix == "" {
return false, nil
}
return isParticipant(ctx, userID, suffix)
},
}
}
type broadcastingAuthResp struct { type broadcastingAuthResp struct {
Auth string `json:"auth"` Auth string `json:"auth"`
ChannelData string `json:"channel_data,omitempty"` ChannelData string `json:"channel_data,omitempty"`
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -77,6 +78,44 @@ func TestBroadcastingAuth_Authorization(t *testing.T) {
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts // A prefix match must not be mistaken for authorization: "…user-a-evil" starts
// with the victim's id but is a different channel. // with the victim's id but is a different channel.
// ConversationRule delegates to the chat service's membership check, so the
// realtime channel and the REST endpoints can never disagree about who is in a
// thread.
func TestConversationRule(t *testing.T) {
const member, outsider = "member-1", "outsider-1"
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
if conversationID == "boom" {
return false, errors.New("database down")
}
return userID == member && conversationID == "conv-1", nil
})
if rule.Prefix != "private-conversation." {
t.Errorf("prefix = %q", rule.Prefix)
}
tests := []struct {
name string
user string
suffix string
wantOK bool
wantErr bool
}{
{"participant", member, "conv-1", true, false},
{"outsider", outsider, "conv-1", false, false},
{"participant of another thread", member, "conv-2", false, false},
{"empty conversation id", member, "", false, false},
{"lookup failure is not a denial", member, "boom", false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
if ok != tc.wantOK || (err != nil) != tc.wantErr {
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
}
})
}
}
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) { func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil") rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
+781
View File
@@ -0,0 +1,781 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// ChatHandler exposes the chat inbox, threads and messages.
//
// Every route is session-authenticated and every read re-checks membership in
// the service, so a conversation id is not a capability: knowing one gets you
// nothing if you were never in the thread (or have left it).
type ChatHandler struct {
svc *service.ChatService
}
func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} }
// writeChatError maps service errors onto status codes. Not-a-participant is a
// 404, not a 403: confirming a thread exists to someone outside it leaks that
// two people are talking.
func writeChatError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone):
response.Error(w, http.StatusNotFound, "conversation not found")
case errors.Is(err, service.ErrAvatarUnset):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrChatBadEntity):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrStickerNotOwned):
response.Error(w, http.StatusForbidden, "that sticker isn't in your collection")
case errors.Is(err, service.ErrChatBadEmoji):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadQuote):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadAvatarKey):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrMessageNotHere):
response.Error(w, http.StatusNotFound, "message not found")
case errors.Is(err, repository.ErrAttachmentGone):
response.Error(w, http.StatusNotFound, "attachment not found")
case errors.Is(err, service.ErrChatMediaOff):
response.Error(w, http.StatusServiceUnavailable, err.Error())
case errors.Is(err, service.ErrChatBadMedia):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable),
errors.Is(err, service.ErrChatAdminsOnly):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole),
errors.Is(err, service.ErrChatBadPolicy):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotSender):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAccepting):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge),
errors.Is(err, service.ErrChatGenderUnset):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBlocked):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatReadOnly):
response.Error(w, http.StatusConflict, "Kautilya is not available yet")
case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong),
errors.Is(err, service.ErrChatBadMuteDuration),
errors.Is(err, repository.ErrReplyTargetNotHere):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "chat request failed")
}
}
// Inbox — GET /chat/conversations?tab=chats|kautilya
func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
tab := r.URL.Query().Get("tab")
items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
type openDMReq struct {
// Handle, not id: profiles are addressed by handle everywhere else, and
// the public profile payload carries no user id.
Handle string `json:"handle"`
}
// Unread — GET /chat/unread. Drives the nav badge, so it stays a single
// count rather than anything the caller has to aggregate.
func (h *ChatHandler) Unread(w http.ResponseWriter, r *http.Request) {
n, err := h.svc.UnreadCount(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
// thread when there is one, so the client can call it every time it opens a
// profile without checking first.
func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req openDMReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" {
response.Error(w, http.StatusBadRequest, "handle is required")
return
}
conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
type createGroupReq struct {
Title string `json:"title"`
MemberHandles []string `json:"member_handles"`
}
// CreateGroup — POST /chat/conversations/group
func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req createGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, conv)
}
// OpenAIThread — POST /chat/conversations/kautilya
func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) {
conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
// Detail — GET /chat/conversations/{id}
func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, detail)
}
// Messages — GET /chat/conversations/{id}/messages?before_seq=&limit=
//
// before_seq is an exclusive upper bound rather than an offset, so paging stays
// stable while new messages keep arriving at the top.
func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
msgs, err := h.svc.Messages(r.Context(), userID, convID,
int64(atoiOr(r.URL.Query().Get("before_seq"), 0)),
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
type mediaReq struct {
Key string `json:"key"`
Mime string `json:"mime"`
SizeBytes *int64 `json:"size_bytes"`
Width *int `json:"width"`
Height *int `json:"height"`
DurationMs *int `json:"duration_ms"`
Blurhash *string `json:"blurhash"`
FileName *string `json:"file_name"`
}
type sendReq struct {
Body string `json:"body"`
ClientMsgID *string `json:"client_msg_id"`
// preview_url is the link the sender chose to keep a card for; omitted
// when they dismissed it. Only the URL — never the card's contents.
PreviewURL *string `json:"preview_url"`
Attachments []mediaReq `json:"attachments"`
// quote is a passage shared from the library. `path` must be app-relative;
// the service refuses anything that could navigate off-site.
Quote *quoteReq `json:"quote"`
// sticker_key sends reward artwork. Ownership is checked server-side.
StickerKey *string `json:"sticker_key"`
// entity shares something else in the app — a post — as a card. Only the
// reference; the BFF resolves the contents and re-checks visibility.
Entity *entityReq `json:"entity"`
ReplyToID *string `json:"reply_to_id"`
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
// its own "can the caller see the source?" check in a later phase, and
// accepting an unvalidated message reference in the meantime would let a
// client point at any message in the system.
}
// Send — POST /chat/conversations/{id}/messages
func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
var req sendReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
media := make([]service.MediaAttachment, 0, len(req.Attachments))
for _, a := range req.Attachments {
media = append(media, service.MediaAttachment{
Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width,
Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash,
FileName: a.FileName,
})
}
var quote *service.QuoteAttachment
if req.Quote != nil {
quote = &service.QuoteAttachment{
Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text,
}
}
var entity *service.EntityAttachment
if req.Entity != nil {
entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID}
}
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
Body: req.Body,
ClientMsgID: req.ClientMsgID,
ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL,
Quote: quote,
StickerKey: req.StickerKey,
Entity: entity,
Attachments: media,
})
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
// Settings — GET /chat/settings
func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, settings)
}
type chatSettingsReq struct {
ReadReceipts *bool `json:"read_receipts"`
WhoCanMessage *string `json:"who_can_message"`
MinAccountAgeMonths *int `json:"min_account_age_months"`
TrainKautilya *bool `json:"train_kautilya"`
}
// UpdateSettings — PATCH /chat/settings
//
// Fields are optional: anything omitted keeps its current value, so a toggle
// doesn't have to send the whole object back.
func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
current, err := h.svc.Settings(r.Context(), userID)
if err != nil {
writeChatError(w, err)
return
}
var req chatSettingsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.ReadReceipts != nil {
current.ReadReceipts = *req.ReadReceipts
}
if req.WhoCanMessage != nil {
current.WhoCanMessage = *req.WhoCanMessage
}
if req.MinAccountAgeMonths != nil {
current.MinAccountAgeMonths = *req.MinAccountAgeMonths
}
if req.TrainKautilya != nil {
current.TrainKautilya = *req.TrainKautilya
}
saved, err := h.svc.SaveSettings(r.Context(), userID, current)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, saved)
}
type linkPreviewReq struct {
URL string `json:"url"`
}
// LinkPreview — POST /chat/link-preview
//
// The composer calls this as a link is typed so the sender can see the card
// and dismiss it before sending. Only the URL travels in either direction's
// trust boundary: the BFF fetches the metadata itself.
func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) {
var req linkPreviewReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
response.Error(w, http.StatusBadRequest, "url is required")
return
}
preview, err := h.svc.LinkPreview(r.Context(), req.URL)
if err != nil {
// No card is a normal outcome, not a server fault.
response.Error(w, http.StatusUnprocessableEntity, "no preview available")
return
}
response.JSON(w, http.StatusOK, preview)
}
type chatPresignReq struct {
ContentType string `json:"content_type"`
}
// PresignAttachment — POST /chat/conversations/{id}/attachments/presign
func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignAttachment(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
// The key comes back so the client can name it when it sends the message;
// there is no public URL, by design.
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// Attachment — GET /chat/attachments/{attachmentID}
//
// A stable URL that redirects to a short-lived signed one. Membership is
// re-checked on every fetch, so access revoked after a link was shared stops
// working — which a signed URL baked into the message payload could not do.
func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.AttachmentURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID"))
if err != nil {
writeChatError(w, err)
return
}
// Private and short-lived: never let a shared cache hold onto it.
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign
func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignGroupAvatar(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// GroupAvatar — GET /chat/conversations/{id}/avatar
//
// Stable URL that redirects to a short-lived signed one, re-checking
// membership each time, exactly as message attachments do.
func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.GroupAvatarURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before=
func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) {
var before *time.Time
if raw := r.URL.Query().Get("before"); raw != "" {
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
before = &t
}
}
items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before,
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Starred — GET /chat/conversations/{id}/starred
func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) {
msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
// Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star
func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, true)
}
func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, false)
}
func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) {
err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone
func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
scope := r.URL.Query().Get("scope")
if scope == "" {
scope = service.DeleteForMe // the safer default
}
err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), scope)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type forwardReq struct {
MessageID string `json:"message_id"`
}
// Forward — POST /chat/conversations/{id}/forward
//
// The path names the DESTINATION; the body names the message being copied in.
func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) {
var req forwardReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
response.Error(w, http.StatusBadRequest, "message_id is required")
return
}
msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()),
req.MessageID, chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
type chatReportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report
func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) {
var req chatReportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Category, req.Reason)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type membersReq struct {
// Handles, not ids: search results and follow lists identify people by
// handle, and it matches /u/{handle}/block elsewhere in the API.
MemberHandles []string `json:"member_handles"`
}
// AddMembers — POST /chat/conversations/{id}/members
func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) {
var req membersReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.MemberHandles); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// RemoveMember — DELETE /chat/conversations/{id}/members/{handle}
//
// Passing your own handle is how you leave.
func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type roleReq struct {
Role string `json:"role"`
}
// SetMemberRole — PATCH /chat/conversations/{id}/members/{userID}
func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) {
var req roleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type updateGroupReq struct {
Title *string `json:"title"`
SendPolicy *string `json:"send_policy"`
// AvatarKey comes from PresignGroupAvatar; the service checks it belongs
// to this group before storing it.
AvatarKey *string `json:"avatar_key"`
}
// UpdateGroup — PATCH /chat/conversations/{id}
func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
var req updateGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type entityReq struct {
Type string `json:"type"`
ID string `json:"id"`
}
type quoteReq struct {
Path string `json:"path"`
Title string `json:"title"`
Text string `json:"text"`
}
type receiptReq struct {
Seq int64 `json:"seq"`
}
// MarkRead — POST /chat/conversations/{id}/read
func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkRead)
}
// MarkDelivered — POST /chat/conversations/{id}/delivered
func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkDelivered)
}
func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) {
var req receiptReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type muteReq struct {
// Duration is one of 8h, 1w, always, off.
Duration string `json:"duration"`
}
// SetMute — POST /chat/conversations/{id}/mute
//
// Muting silences alerts for the caller only; the thread keeps receiving
// messages and its unread count keeps climbing.
func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
var req muteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
}
// Message — GET /chat/conversations/{id}/messages/{messageID}
func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) {
msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msg)
}
// MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions
//
// Only read when someone opens the list; the thread itself renders from the
// grouped counts that ride along with each message.
func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.MessageReactions(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
// Stickers — GET /chat/stickers. What the caller may send.
func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type saveStickerReq struct {
Key string `json:"key"`
}
// SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker
//
// Keyed to the message it was sent on, not just the sticker key: that is what
// proves the caller was actually sent it rather than guessing at the catalog.
func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) {
var req saveStickerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Key); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Typing — POST /chat/conversations/{id}/typing
//
// Fire-and-forget: nothing is stored and the response carries nothing, because
// a typing indicator is stale within seconds of being sent.
func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Typing(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type reactReq struct {
Emoji string `json:"emoji"`
}
// React — PUT /chat/conversations/{id}/messages/{messageID}/reaction.
// Replaces any previous choice.
func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) {
var req reactReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// The message id is authoritative: React re-derives the conversation from
// it and authorizes there, so the id in the path can't be used to react
// into a thread the caller isn't in.
if err := h.svc.React(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Emoji); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction.
// Idempotent.
func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Receipts — GET /chat/conversations/{id}/receipts
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, rc)
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return fallback
}
return n
}
+250
View File
@@ -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)
}
+108
View File
@@ -2,11 +2,15 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
@@ -114,6 +118,110 @@ func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) {
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the // AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
// client has shown the confetti, so a gift surfaces exactly once. // client has shown the confetti, so a gift surfaces exactly once.
// OpenGift — POST /sadhna/rewards/gifts/{key}/open
//
// Called after the celebration is dismissed. Idempotent: opening an already
// opened gift changes nothing.
func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.OpenGift(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil {
response.Error(w, http.StatusInternalServerError, "could not open that gift")
return
}
response.NoContent(w)
}
// GiftableRewards — GET /admin/reward-gifts/catalog
func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.GiftableRewards(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load the catalog")
return
}
response.JSON(w, http.StatusOK, list)
}
// DeleteCustomGift — DELETE /admin/reward-gifts/{key}
func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such custom gift")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.NoContent(w)
}
type customGiftReq struct {
Name string `json:"name"`
Meaning string `json:"meaning"`
ImageURL string `json:"image_url"`
}
// CreateCustomGift — POST /admin/reward-gifts/catalog
//
// Adds a sticker to the catalog. It reaches nobody until it is sent.
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
var req customGiftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusCreated, reward)
}
// PresignGiftImage — POST /admin/reward-gifts/image-presign
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
var req struct {
ContentType string `json:"content_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload, "image_url": public,
})
}
type giftReq struct {
RewardKey string `json:"reward_key"`
}
// GiftToEveryone — POST /admin/reward-gifts
//
// Hands one reward to every account, unopened. Idempotent, so a repeated
// click tops up only the people who don't have it rather than duplicating.
func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) {
var req giftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" {
response.Error(w, http.StatusBadRequest, "reward_key is required")
return
}
n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey)
if err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such reward")
return
}
response.Error(w, http.StatusInternalServerError, "could not send the gift")
return
}
response.JSON(w, http.StatusOK, map[string]int64{"granted": n})
}
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) { func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context()) uid := middleware.UserID(r.Context())
if err := h.svc.AckRewards(r.Context(), uid); err != nil { if err := h.svc.AckRewards(r.Context(), uid); err != nil {
+6
View File
@@ -83,6 +83,11 @@ const searchStopwordsSet = "common"
// searchResultItem represents a single search result in the API response. // searchResultItem represents a single search result in the API response.
type searchResultItem struct { type searchResultItem struct {
Collection string `json:"collection"` Collection string `json:"collection"`
// DocID is the underlying Mongo _id hex — the indexer uses it as the
// Typesense document id. Surfaced so the admin panel can pin a specific
// item as the Vāṇī of the Day; it identifies public content, not anything
// private.
DocID string `json:"doc_id,omitempty"`
Slug string `json:"slug,omitempty"` Slug string `json:"slug,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
item := searchResultItem{ item := searchResultItem{
Collection: collection, Collection: collection,
DocID: getStringField(doc, "id"),
URL: getStringField(doc, "url"), URL: getStringField(doc, "url"),
score: score, score: score,
} }
+32 -5
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@@ -41,11 +42,29 @@ type SoketiWebhookHandler struct {
appKey string appKey string
appSecret string appSecret string
feed *repository.FeedRepo feed *repository.FeedRepo
// chat records presence so a message doesn't push to someone who is
// already looking at the app. Optional.
chat ChatPresence
logger *zap.Logger logger *zap.Logger
} }
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler { // ChatPresence is the slice of ChatService this handler needs — narrow so the
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger} // webhook doesn't depend on the whole chat surface.
type ChatPresence interface {
MarkPresence(ctx context.Context, userID string, online bool)
}
func (h *SoketiWebhookHandler) presence(ctx context.Context, userID string, online bool) {
if h.chat == nil {
return
}
h.chat.MarkPresence(ctx, userID, online)
}
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, chat ChatPresence, logger *zap.Logger) *SoketiWebhookHandler {
return &SoketiWebhookHandler{
appKey: appKey, appSecret: appSecret, feed: feed, chat: chat, logger: logger,
}
} }
type soketiWebhookBody struct { type soketiWebhookBody struct {
@@ -91,12 +110,20 @@ func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
cleared := 0 cleared := 0
for _, e := range payload.Events { for _, e := range payload.Events {
// A per-user notifications channel emptying = that user is offline. uid, ok := userIDFromNotifChannel(e.Channel)
if e.Name != "channel_vacated" { if !ok {
continue continue
} }
if uid, ok := userIDFromNotifChannel(e.Channel); ok { switch e.Name {
case "channel_occupied":
// This user has a live connection, so chat push is suppressed
// for them — they will see the message in the app.
h.presence(ctx, uid, true)
case "channel_vacated":
// Their last tab closed: they are offline. Evict the feed
// seen-set and let chat push resume.
h.feed.Reset(ctx, uid) h.feed.Reset(ctx, uid)
h.presence(ctx, uid, false)
cleared++ cleared++
} }
} }
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
type StaffHandler struct {
svc *service.StaffService
}
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
func writeStaffError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
errors.Is(err, repository.ErrUserGone):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
errors.Is(err, repository.ErrRoleKeyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
errors.Is(err, service.ErrRoleNoPermissions):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "staff request failed")
}
}
// Me — GET /admin/me. The caller's role and effective permissions, which the
// panel uses to hide what they can't do. Hiding is a courtesy: every route
// enforces its own permission regardless.
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
s := middleware.Staff(r.Context())
response.JSON(w, http.StatusOK, service.Me{
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
})
}
// Catalog — GET /admin/permissions. What a custom role may be built from.
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
response.JSON(w, http.StatusOK, rbac.Catalog)
}
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.List(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type grantReq struct {
Handle string `json:"handle"`
RoleID string `json:"role_id"`
}
// Grant — POST /admin/staff
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
var req grantReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
return
}
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// ChangeRole — PATCH /admin/staff/{id}
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
var req struct {
RoleID string `json:"role_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "role_id is required")
return
}
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Revoke — DELETE /admin/staff/{id}
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
roles, err := h.svc.Roles(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, roles)
}
type adminRoleReq struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusCreated, ro)
}
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, ro)
}
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
+126
View File
@@ -1,8 +1,15 @@
package handler package handler
import ( import (
"encoding/json"
"errors"
"net/http" "net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
@@ -18,6 +25,125 @@ func NewVaniHandler(svc *service.VaniService) *VaniHandler {
// Today — GET /vani/today. Public; the same pick is returned to everyone for // Today — GET /vani/today. Public; the same pick is returned to everyone for
// the current UTC day. Responds with `data: null` if there's no content. // the current UTC day. Responds with `data: null` if there's no content.
type setVaniReq struct {
// Day as YYYY-MM-DD (UTC). Empty means today.
Day string `json:"day"`
Kind string `json:"kind"`
// RefID is the content's Mongo _id hex, which search returns as doc_id.
RefID string `json:"ref_id"`
}
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
var req setVaniReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
type setVaniByURLReq struct {
Day string `json:"day"`
URL string `json:"url"`
}
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
var req setVaniByURLReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
day, err := parseVaniDay(chi.URLParam(r, "day"))
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
if err := h.svc.ClearForDay(r.Context(), day); err != nil {
writeVaniError(w, err)
return
}
response.NoContent(w)
}
// Schedule — GET /admin/vani. Decided days from today onward.
func (h *VaniHandler) Schedule(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
picks, err := h.svc.Schedule(r.Context(), limit)
if err != nil {
writeVaniError(w, err)
return
}
out := make([]map[string]any, 0, len(picks))
for _, p := range picks {
out = append(out, map[string]any{
"day": p.Day.Format("2006-01-02"),
"kind": p.Kind,
"ref_id": p.RefID,
"payload": p.Payload,
})
}
response.JSON(w, http.StatusOK, out)
}
// parseVaniDay accepts YYYY-MM-DD, or empty for today (UTC — the pick is
// keyed on a UTC calendar day so everyone sees the same item).
func parseVaniDay(s string) (time.Time, error) {
if strings.TrimSpace(s) == "" {
return time.Now().UTC().Truncate(24 * time.Hour), nil
}
return time.Parse("2006-01-02", strings.TrimSpace(s))
}
func writeVaniError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrVaniRefInvalid),
errors.Is(err, service.ErrVaniBadKind),
errors.Is(err, service.ErrVaniBadURL):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrVaniRefNotFound):
response.Error(w, http.StatusNotFound, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "vāṇī request failed")
}
}
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) { func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
payload, err := h.svc.Today(r.Context()) payload, err := h.svc.Today(r.Context())
if err != nil { if err != nil {
+53 -8
View File
@@ -1,17 +1,38 @@
package middleware package middleware
import ( import (
"context"
"errors"
"net/http" "net/http"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/response"
) )
// RequireAdmin gates a route to users whose role is "admin". It MUST run after type staffCtxKey struct{}
// RequireAuth (it reads the authenticated user id from context, then looks up
// the row to check the role). Non-admins get 403; this keeps the whole // StaffContext is the caller's resolved admin access, set by RequireStaff.
// /api/v1/admin/* surface invisible to ordinary accounts. type StaffContext struct {
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler { RoleKey string
RoleName string
Perms rbac.Set
}
// Staff returns the caller's staff context, or nil outside RequireStaff.
func Staff(ctx context.Context) *StaffContext {
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
return s
}
// RequireStaff admits accounts that hold an admin role and aren't blocked from
// the panel. It MUST run after RequireAuth.
//
// It resolves the role's permissions once per request and puts them in
// context, so the RequirePermission checks on each route are map lookups
// rather than further queries. Being staff gets you through the door only;
// every route then names the permission it needs.
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := UserID(r.Context()) uid := UserID(r.Context())
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
response.Error(w, http.StatusUnauthorized, "not authenticated") response.Error(w, http.StatusUnauthorized, "not authenticated")
return return
} }
u, err := users.FindByID(r.Context(), uid) access, err := staff.Access(r.Context(), uid)
if errors.Is(err, repository.ErrNotStaff) {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
if err != nil { if err != nil {
response.Error(w, http.StatusInternalServerError, "auth check failed") response.Error(w, http.StatusInternalServerError, "auth check failed")
return return
} }
if u.Role != "admin" { if access.Blocked {
response.Error(w, http.StatusForbidden, "admin access required") response.Error(w, http.StatusForbidden, "your admin access has been suspended")
return
}
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
RoleKey: access.RoleKey,
RoleName: access.RoleName,
Perms: rbac.NewSet(access.Permissions),
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequirePermission gates a route to staff holding perm. Must run inside
// RequireStaff; outside it there is no staff context and everything is refused.
func RequirePermission(perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := Staff(r.Context())
if s == nil || !s.Perms.Has(perm) {
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
)
func withStaff(perms ...string) context.Context {
return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{
RoleKey: "x", Perms: rbac.NewSet(perms),
})
}
func TestRequirePermission(t *testing.T) {
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
gate := RequirePermission("users:ban")(ok)
cases := []struct {
name string
ctx context.Context
want int
}{
{"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden},
{"admin with it", withStaff("users:ban"), http.StatusNoContent},
{"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent},
// Outside RequireStaff there's no staff context at all: refuse.
{"no staff context", context.Background(), http.StatusForbidden},
}
for _, c := range cases {
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx))
if rec.Code != c.want {
t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want)
}
}
// Staff management is never reachable through a catalog permission.
if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) {
t.Error("a non-wildcard set holds admins:manage")
}
}
func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) }
+2
View File
@@ -17,6 +17,8 @@ type AdminUser struct {
Plan string `json:"plan"` Plan string `json:"plan"`
EmailVerifiedAt *time.Time `json:"email_verified_at"` EmailVerifiedAt *time.Time `json:"email_verified_at"`
BannedAt *time.Time `json:"banned_at"` BannedAt *time.Time `json:"banned_at"`
// AdminRole is the staff role key, nil for ordinary accounts.
AdminRole *string `json:"admin_role,omitempty"`
PostCount int `json:"post_count"` PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+40
View File
@@ -0,0 +1,40 @@
package model
import (
"encoding/json"
"time"
)
// Broadcast is an announcement shown to every user exactly once.
//
// Deliberately not a notification: it creates no tray rows and no per-user
// records until someone actually dismisses it. The modal is the delivery
// surface; push and the realtime nudge only tell clients to go look.
type Broadcast struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
// Entities are offset/length formatting spans, the same shape chat uses,
// so no HTML is ever stored or rendered.
Entities json.RawMessage `json:"entities"`
// BannerURL is resolved from the stored R2 key for the client.
BannerURL *string `json:"banner_url,omitempty"`
CTALabel *string `json:"cta_label,omitempty"`
CTAURL *string `json:"cta_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// SeenCount is filled for the admin list only — how many people have
// dismissed it so far.
SeenCount int64 `json:"seen_count,omitempty"`
}
// Live reports whether the broadcast is currently deliverable.
func (b *Broadcast) Live(now time.Time) bool {
if b.PublishedAt == nil || b.PublishedAt.After(now) {
return false
}
return b.ExpiresAt == nil || b.ExpiresAt.After(now)
}
+302
View File
@@ -0,0 +1,302 @@
package model
import (
"encoding/json"
"time"
)
// Who may start a conversation with a user.
const (
WhoAnyone = "anyone"
WhoFriends = "friends"
WhoAccountAge = "account_age"
WhoSameGender = "same_gender"
)
// ChatSettings are a user's own chat preferences. Absence of a stored row means
// all defaults, so DefaultChatSettings and the column defaults must agree.
type ChatSettings struct {
ReadReceipts bool `json:"read_receipts"`
WhoCanMessage string `json:"who_can_message"`
MinAccountAgeMonths int `json:"min_account_age_months"`
TrainKautilya bool `json:"train_kautilya"`
// GenderSet reports whether the user has a gender on their profile. The
// same_gender option is unusable without one, so the UI needs to know.
GenderSet bool `json:"gender_set"`
}
// DefaultChatSettings mirrors the column defaults in migration 0050.
func DefaultChatSettings() ChatSettings {
return ChatSettings{
ReadReceipts: true,
WhoCanMessage: WhoAnyone,
MinAccountAgeMonths: 3,
TrainKautilya: true,
}
}
// Conversation kinds. DMs and groups fill the inbox's "Chats" tab; ai fills
// the "Kautilya" tab.
// Group send policies. Only groups honour these — a DM has no administrators
// and the Kautilya thread has one participant.
const (
SendPolicyEveryone = "everyone"
SendPolicyAdmins = "admins"
)
// Participant roles, most privileged first.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
const (
ConversationDM = "dm"
ConversationGroup = "group"
ConversationAI = "ai"
)
// Message kinds describe who produced a message, not what it contains — the
// content type lives on its attachments.
const (
MessageText = "text"
MessageSystem = "system"
MessageAssistant = "assistant"
)
// Conversation is a chat thread.
type Conversation struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title *string `json:"title,omitempty"`
AvatarKey *string `json:"avatar_key,omitempty"`
// SendPolicy is "everyone" or "admins"; meaningful for groups only.
SendPolicy string `json:"send_policy"`
LastSeq int64 `json:"last_seq"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ChatPeer is the counterpart shown on a DM row: enough to render the inbox
// without a second round trip.
type ChatPeer struct {
ID string `json:"id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
// Role and JoinedAt are filled for a thread's participant list; they are
// empty on the `peer` of a DM, where a role means nothing.
Role string `json:"role,omitempty"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
}
// MessagePreview is the truncated last-message line on an inbox row.
type MessagePreview struct {
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Kind string `json:"kind"`
Body string `json:"body"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// InboxItem is one row of the chat inbox.
type InboxItem struct {
Conversation
Unread int64 `json:"unread"`
// MutedUntil is non-nil while the caller has this thread muted. Unread
// still accrues — muting silences the alert, it does not hide the chat.
MutedUntil *time.Time `json:"muted_until,omitempty"`
Peer *ChatPeer `json:"peer,omitempty"` // DMs only
LastMessage *MessagePreview `json:"last_message,omitempty"` // nil on an empty thread
}
// ChatRelationship is how two people in a DM stand with each other — what the
// chat profile shows above the shared-media tabs.
type ChatRelationship struct {
// Following: the caller follows the peer. FollowedBy: the reverse. Both
// true is mutual; one true is a one-way follow.
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// FriendsSince is when the first follow between them was accepted — the
// "friends since" line. Nil when neither follows the other.
FriendsSince *time.Time `json:"friends_since,omitempty"`
}
// ConversationDetail is everything a thread screen needs in one request:
// the thread, who is in it, and the caller's own state. It replaces scanning
// the inbox to find a single conversation.
type ConversationDetail struct {
Conversation
// Peer is the counterpart on a DM, nil otherwise.
Peer *ChatPeer `json:"peer,omitempty"`
// Participants is everyone still in the thread, the caller included, so
// the UI can name a message's sender without another lookup.
Participants []ChatPeer `json:"participants"`
Unread int64 `json:"unread"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
// MyRole is the caller's role in this thread, so the UI can show admin
// controls without deriving it from the participant list.
MyRole string `json:"my_role"`
// Relationship is populated for DMs only; a group has no single peer to
// stand in a relationship with.
Relationship *ChatRelationship `json:"relationship,omitempty"`
}
// Participant is a user's membership of a conversation, including the two
// cursors that drive tick state.
type Participant struct {
ConversationID string `json:"conversation_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
LastReadSeq int64 `json:"last_read_seq"`
LastDeliveredSeq int64 `json:"last_delivered_seq"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
JoinedAt time.Time `json:"joined_at"`
LeftAt *time.Time `json:"left_at,omitempty"`
}
// Attachment kinds.
const (
AttachmentImage = "image"
AttachmentVideo = "video"
AttachmentAudio = "audio"
AttachmentFile = "file"
AttachmentLink = "link"
AttachmentEntity = "entity"
// AttachmentQuote is a passage shared from the library: url is an
// app-relative deep link back to the phrase, title the source reference,
// description the passage itself.
AttachmentQuote = "quote"
// AttachmentSticker is reward artwork: image_url and title carry the art
// and its name, entity_id the reward key a recipient can save by.
AttachmentSticker = "sticker"
)
// Entity types a message can reference.
const (
EntityPost = "post"
// EntitySticker marks a sticker attachment's entity_id as a reward key.
EntitySticker = "sticker"
)
// Attachment is a media file, link preview or shared entity on a message.
//
// Media carries an R2 key rather than a URL: chat media lives in the private
// bucket and is only ever reachable through a participant-checked, short-lived
// presigned GET, so there is no URL to store.
type Attachment struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
Ord int `json:"ord"`
Kind string `json:"kind"`
// Media
Mime *string `json:"mime,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
DurationMs *int `json:"duration_ms,omitempty"`
Blurhash *string `json:"blurhash,omitempty"`
FileName *string `json:"file_name,omitempty"`
// Link preview. Metadata is fetched by the BFF, never supplied by a
// client — otherwise any link could be made to claim anything.
URL *string `json:"url,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
SiteName *string `json:"site_name,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
// Shared entity (a post, for now). EntityType/EntityID are the durable
// reference; Entity is resolved per viewer at read time, so the same row
// renders differently — or not at all — depending on who is looking.
EntityType *string `json:"entity_type,omitempty"`
EntityID *string `json:"entity_id,omitempty"`
Entity json.RawMessage `json:"entity,omitempty"`
// EntityUnavailable is set when the entity was deleted, hidden, or the
// viewer has since lost access. The client renders a tombstone.
EntityUnavailable bool `json:"entity_unavailable,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ReplyPreview is the quoted snippet shown above a reply. Deliberately small:
// the bubble needs a name and a line of text, not the whole message.
type ReplyPreview struct {
ID string `json:"id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Body string `json:"body"`
Deleted bool `json:"deleted"`
}
// Message is one chat message. Entities carries the formatting spans produced
// by ParseEntities; it is always a JSON array, never null.
type Message struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
Kind string `json:"kind"`
Body string `json:"body"`
Entities json.RawMessage `json:"entities"`
ClientMsgID *string `json:"client_msg_id,omitempty"`
ReplyToID *string `json:"reply_to_id,omitempty"`
ForwardedFromID *string `json:"forwarded_from_id,omitempty"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// ReplyTo is hydrated on read when this message replies to another in the
// SAME conversation. Nil otherwise.
ReplyTo *ReplyPreview `json:"reply_to,omitempty"`
// Attachments is always present (possibly empty) so clients need no nil
// check before iterating.
Attachments []Attachment `json:"attachments"`
// Reactions is always present (possibly empty), same contract as
// Attachments — clients render the chip row without a nil check.
Reactions []Reaction `json:"reactions"`
}
// Sticker is one piece of reward artwork, as it appears in the picker and on
// a message.
type Sticker struct {
Key string `json:"key"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
// Saved is true when it came from a chat rather than from sādhana, so the
// picker can say where it came from.
Saved bool `json:"saved"`
}
// ReactionDetail is one person's reaction, for the "who reacted" list.
type ReactionDetail struct {
User ChatPeer `json:"user"`
Emoji string `json:"emoji"`
CreatedAt time.Time `json:"created_at"`
}
// Reaction is one emoji grouped across the people who chose it.
//
// Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the
// full membership of every reaction would grow with the room.
type Reaction struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
// Mine marks the viewer's own choice, so the chip can be highlighted and
// tapping it toggles off.
Mine bool `json:"mine"`
}
// Receipts are the conversation-wide delivery cursors used to draw ticks on
// the sender's own messages. Both are the MINIMUM across the other
// participants, so in a group a message is "read" only once everyone has read
// it — matching what users expect from the blue double tick.
type Receipts struct {
DeliveredSeq int64 `json:"delivered_seq"`
ReadSeq int64 `json:"read_seq"`
}
+135
View File
@@ -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"`
}
+8 -1
View File
@@ -52,9 +52,16 @@ type SadhnaReward struct {
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a // SadhnaRewardGrant is a reward a user earned at a streak milestone (a
// multiple of 7 days). The full reward is embedded for direct rendering. // multiple of 7 days). The full reward is embedded for direct rendering.
type SadhnaRewardGrant struct { type SadhnaRewardGrant struct {
Milestone int `json:"milestone"` // Milestone is the streak day this was earned on. Nil for an admin gift,
// which isn't tied to a streak at all.
Milestone *int `json:"milestone"`
GrantedAt time.Time `json:"granted_at"` GrantedAt time.Time `json:"granted_at"`
Reward SadhnaReward `json:"reward"` Reward SadhnaReward `json:"reward"`
// Source is "streak" or "gift".
Source string `json:"source"`
// Opened is false while a gift is still wrapped — the gallery renders
// those blurred until the devotee opens them.
Opened bool `json:"opened"`
} }
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It // SadhnaDashboard is the single payload the /sadhna-streak page reads. It
+9
View File
@@ -183,6 +183,15 @@ type RelationshipState struct {
RequestsYou bool `json:"requests_you"` RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"` Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"` BlockedBy bool `json:"blocked_by"`
// CanMessage reports whether the viewer may START a conversation with this
// profile, given the target's "who can message you" setting and any block.
// It lets the UI decide whether to offer a Message button rather than
// offering one that fails on tap.
CanMessage bool `json:"can_message"`
// MessageRestriction names the target's rule when CanMessage is false, so
// the UI can explain instead of silently hiding the button. Empty when
// messaging is allowed, or "blocked" when a block is the reason.
MessageRestriction string `json:"message_restriction,omitempty"`
} }
// Follow is one row of the follows table — a directed, request-based // Follow is one row of the follows table — a directed, request-based
+37
View File
@@ -0,0 +1,37 @@
package model
import "time"
// AdminRole is a named set of admin-panel permissions.
type AdminRole struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
IsSystem bool `json:"is_system"`
Permissions []string `json:"permissions"`
// MemberCount is how many staff hold the role, for the roles screen.
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
}
// StaffMember is one account with admin-panel access.
type StaffMember struct {
UserID string `json:"user_id"`
Handle string `json:"handle"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
}
// StaffAccess is what the admin middleware needs about the caller.
type StaffAccess struct {
RoleKey string
RoleName string
Permissions []string
Blocked bool
}
+108
View File
@@ -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
}
+2 -1
View File
@@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers, FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit, u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
u.disciple_id, u.disciple_id,
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
COUNT(*) OVER() AS total COUNT(*) OVER() AS total
FROM users u FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%' WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers, &u.HasPassword, &u.Providers,
&bookmarkLimit, &publicCollLimit, &privateCollLimit, &bookmarkLimit, &publicCollLimit, &privateCollLimit,
&u.DiscipleID, &total); err != nil { &u.DiscipleID, &u.AdminRole, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err) return nil, 0, fmt.Errorf("scan admin user: %w", err)
} }
// Resolve plan default + overrides so the panel shows what's in force. // Resolve plan default + overrides so the panel shows what's in force.
+151
View File
@@ -0,0 +1,151 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBroadcastGone = errors.New("broadcast not found")
type BroadcastRepo struct{ pg *Postgres }
func NewBroadcastRepo(pg *Postgres) *BroadcastRepo { return &BroadcastRepo{pg: pg} }
const broadcastColumns = `id, title, body, entities, banner_key, cta_label,
cta_url, published_at, expires_at, created_at`
// scanBroadcast decodes a row produced by broadcastColumns. bannerKey stays
// out of the model — the service turns it into a URL.
func scanBroadcast(row pgx.Row, b *model.Broadcast, bannerKey **string) error {
return row.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, bannerKey,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt)
}
type CreateBroadcastParams struct {
Title string
Body string
Entities []byte
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
CreatedBy string
}
func (r *BroadcastRepo) Create(ctx context.Context, p CreateBroadcastParams) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
INSERT INTO broadcasts (title, body, entities, banner_key, cta_label,
cta_url, expires_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+broadcastColumns,
p.Title, p.Body, p.Entities, p.BannerKey, p.CTALabel, p.CTAURL,
p.ExpiresAt, p.CreatedBy), &b, &key)
if err != nil {
return nil, nil, fmt.Errorf("create broadcast: %w", err)
}
return &b, key, nil
}
// Publish stamps published_at, making the broadcast deliverable. Idempotent:
// re-publishing keeps the original timestamp so the live window doesn't shift.
func (r *BroadcastRepo) Publish(ctx context.Context, id string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
UPDATE broadcasts
SET published_at = COALESCE(published_at, now()), updated_at = now()
WHERE id = $1
RETURNING `+broadcastColumns, id), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrBroadcastGone
}
if err != nil {
return nil, nil, fmt.Errorf("publish broadcast: %w", err)
}
return &b, key, nil
}
func (r *BroadcastRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM broadcasts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete broadcast: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBroadcastGone
}
return nil
}
// Pending returns the newest live broadcast this user has not dismissed.
func (r *BroadcastRepo) Pending(ctx context.Context, userID string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
SELECT `+broadcastColumns+`
FROM broadcasts b
WHERE b.published_at IS NOT NULL
AND b.published_at <= now()
AND (b.expires_at IS NULL OR b.expires_at > now())
AND NOT EXISTS (
SELECT 1 FROM broadcast_views v
WHERE v.broadcast_id = b.id AND v.user_id = $1)
-- Newest first. Announcements are time-sensitive, so a stale one
-- must not gate a fresh one; dismissing this reveals the next, so
-- nothing is skipped either way — only the order differs.
ORDER BY b.published_at DESC
LIMIT 1`, userID), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil // nothing waiting is not an error
}
if err != nil {
return nil, nil, fmt.Errorf("pending broadcast: %w", err)
}
return &b, key, nil
}
// MarkSeen records a dismissal. Idempotent — two tabs can race.
func (r *BroadcastRepo) MarkSeen(ctx context.Context, id, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO broadcast_views (broadcast_id, user_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`, id, userID)
if err != nil {
return fmt.Errorf("mark broadcast seen: %w", err)
}
return nil
}
// List returns broadcasts newest first, with dismissal counts, for the admin.
func (r *BroadcastRepo) List(ctx context.Context, limit int) ([]model.Broadcast, []*string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT `+broadcastColumns+`,
(SELECT count(*) FROM broadcast_views v WHERE v.broadcast_id = b.id)
FROM broadcasts b
ORDER BY COALESCE(b.published_at, b.created_at) DESC
LIMIT $1`, limit)
if err != nil {
return nil, nil, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := []model.Broadcast{}
keys := []*string{}
for rows.Next() {
var b model.Broadcast
var key *string
if err := rows.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, &key,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt,
&b.SeenCount); err != nil {
return nil, nil, fmt.Errorf("scan broadcast: %w", err)
}
out = append(out, b)
keys = append(keys, key)
}
return out, keys, rows.Err()
}
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// These exercise real SQL against a real Postgres — the LATERAL joins, the
// FOR UPDATE sequencing and the partial indexes are the parts most likely to be
// wrong, and none of them can be checked without a database. Skipped when no
// POSTGRES_URI is configured, so `go test ./...` still passes anywhere.
func testRepo(t *testing.T) (*ChatRepo, *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 — skipping chat repository integration tests")
}
pg, err := NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return NewChatRepo(pg), pg
}
// makeUser inserts a throwaway user and removes it (and anything cascading
// from it) when the test finishes.
func makeUser(t *testing.T, pg *Postgres, label string) string {
t.Helper()
ctx := context.Background()
id := uuid.NewString()
suffix := id[:8]
_, err := pg.Pool.Exec(ctx, `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, fmt.Sprintf("chattest-%s@example.invalid", suffix), "Chat Test "+label, "chattest_"+suffix)
if err != nil {
t.Fatalf("create test user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// dropConversation removes a thread when the test ends; messages, participants
// and attachments cascade with it.
func dropConversation(t *testing.T, pg *Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
})
}
func send(t *testing.T, r *ChatRepo, convID, sender, body string) *model.Message {
t.Helper()
m, _, err := r.Append(context.Background(), AppendParams{
ConversationID: convID, SenderID: &sender, Kind: model.MessageText, Body: body,
})
if err != nil {
t.Fatalf("append %q: %v", body, err)
}
return m
}
func TestEnsureDM_IsIdempotentFromEitherSide(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
first, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatalf("EnsureDM: %v", err)
}
dropConversation(t, pg, first.ID)
// Same pair, opposite argument order — must resolve to the same thread,
// which is the whole point of the sorted dm_key.
second, err := r.EnsureDM(ctx, b, a)
if err != nil {
t.Fatalf("EnsureDM reversed: %v", err)
}
if second.ID != first.ID {
t.Errorf("reversed EnsureDM made a second thread: %s vs %s", second.ID, first.ID)
}
for _, u := range []string{a, b} {
if _, err := r.Participant(ctx, first.ID, u); err != nil {
t.Errorf("user %s is not a participant: %v", u, err)
}
}
}
func TestParticipant_RejectsOutsiders(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, outsider := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
if _, err := r.Participant(ctx, c.ID, outsider); !errors.Is(err, ErrNotParticipant) {
t.Errorf("outsider got %v, want ErrNotParticipant", err)
}
}
func TestAppend_AssignsGaplessSequence(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := int64(1); i <= 3; i++ {
m := send(t, r, c.ID, a, fmt.Sprintf("msg %d", i))
if m.Seq != i {
t.Errorf("message %d got seq %d", i, m.Seq)
}
}
var lastSeq int64
if err := pg.Pool.QueryRow(ctx, `SELECT last_seq FROM conversations WHERE id=$1`, c.ID).Scan(&lastSeq); err != nil {
t.Fatal(err)
}
if lastSeq != 3 {
t.Errorf("conversation.last_seq = %d, want 3", lastSeq)
}
}
// A retried send must return the original message and must NOT burn a sequence
// number — a gap would break the client's catch-up cursor.
func TestAppend_DeduplicatesRetries(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
clientID := uuid.NewString()
p := AppendParams{ConversationID: c.ID, SenderID: &a, Kind: model.MessageText,
Body: "only once", ClientMsgID: &clientID}
first, created, err := r.Append(ctx, p)
if err != nil || !created {
t.Fatalf("first append: created=%v err=%v", created, err)
}
again, created, err := r.Append(ctx, p)
if err != nil {
t.Fatalf("retry: %v", err)
}
if created {
t.Error("retry reported created=true")
}
if again.ID != first.ID || again.Seq != first.Seq {
t.Errorf("retry returned a different message: %s/%d vs %s/%d", again.ID, again.Seq, first.ID, first.Seq)
}
var count, lastSeq int64
_ = pg.Pool.QueryRow(ctx, `SELECT count(*), max(seq) FROM messages WHERE conversation_id=$1`, c.ID).Scan(&count, &lastSeq)
if count != 1 || lastSeq != 1 {
t.Errorf("after retry: %d messages, last seq %d — want 1 and 1", count, lastSeq)
}
}
func TestMessages_KeysetPaging(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 1; i <= 5; i++ {
send(t, r, c.ID, a, fmt.Sprintf("m%d", i))
}
page1, err := r.Messages(ctx, c.ID, a, 0, 2)
if err != nil {
t.Fatal(err)
}
if len(page1) != 2 || page1[0].Seq != 5 || page1[1].Seq != 4 {
t.Fatalf("page1 = %v, want seq 5,4", seqsOf(page1))
}
page2, err := r.Messages(ctx, c.ID, a, page1[len(page1)-1].Seq, 2)
if err != nil {
t.Fatal(err)
}
if len(page2) != 2 || page2[0].Seq != 3 || page2[1].Seq != 2 {
t.Fatalf("page2 = %v, want seq 3,2", seqsOf(page2))
}
// entities must come back as a JSON array, never null — the client indexes
// into it without a nil check.
var spans []map[string]any
if err := json.Unmarshal(page1[0].Entities, &spans); err != nil {
t.Errorf("entities not valid JSON: %v (%s)", err, page1[0].Entities)
}
}
// Delete-for-me must hide a message from that user's history AND from their
// inbox preview, while leaving it visible to everyone else.
func TestMessageHides_ArePerUser(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "keep")
hidden := send(t, r, c.ID, a, "hide me")
if _, err := pg.Pool.Exec(ctx,
`INSERT INTO message_hides (message_id, user_id) VALUES ($1,$2)`, hidden.ID, a); err != nil {
t.Fatal(err)
}
mine, _ := r.Messages(ctx, c.ID, a, 0, 10)
if len(mine) != 1 || mine[0].Body != "keep" {
t.Errorf("hider still sees %v", bodiesOf(mine))
}
theirs, _ := r.Messages(ctx, c.ID, b, 0, 10)
if len(theirs) != 2 {
t.Errorf("other participant sees %v, want both", bodiesOf(theirs))
}
inbox, err := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil || row.LastMessage == nil {
t.Fatal("conversation missing from hider's inbox")
}
if row.LastMessage.Body != "keep" {
t.Errorf("hider's preview = %q, want the un-hidden message", row.LastMessage.Body)
}
}
func TestInbox_UnreadPeerAndPreview(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "first")
send(t, r, c.ID, a, "latest")
// Recipient has read nothing.
inbox, err := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil {
t.Fatal("thread missing from recipient inbox")
}
if row.Unread != 2 {
t.Errorf("unread = %d, want 2", row.Unread)
}
if row.LastMessage == nil || row.LastMessage.Body != "latest" {
t.Errorf("preview = %+v, want body %q", row.LastMessage, "latest")
}
if row.LastMessage != nil && row.LastMessage.SenderName == "" {
t.Error("preview has no sender name — the inbox line needs it")
}
if row.Peer == nil || row.Peer.ID != a {
t.Errorf("peer = %+v, want user A", row.Peer)
}
// The sender's own cursor advanced on send, so their unread is zero.
senderInbox, _ := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if sr := findConv(senderInbox, c.ID); sr == nil || sr.Unread != 0 {
t.Errorf("sender unread = %v, want 0", sr)
}
if err := r.MarkRead(ctx, c.ID, b, 2); err != nil {
t.Fatal(err)
}
after, _ := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if row := findConv(after, c.ID); row == nil || row.Unread != 0 {
t.Errorf("after MarkRead unread = %v, want 0", row)
}
}
// Cursors must never move backwards: a replayed or out-of-order receipt would
// otherwise resurrect already-read messages as unread.
func TestCursors_NeverGoBackwards(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 0; i < 3; i++ {
send(t, r, c.ID, a, "m")
}
if err := r.MarkRead(ctx, c.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, c.ID, b, 1); err != nil { // stale replay
t.Fatal(err)
}
p, err := r.Participant(ctx, c.ID, b)
if err != nil {
t.Fatal(err)
}
if p.LastReadSeq != 3 {
t.Errorf("last_read_seq = %d after a stale replay, want 3", p.LastReadSeq)
}
if p.LastDeliveredSeq != 3 {
t.Errorf("reading should imply delivery: last_delivered_seq = %d, want 3", p.LastDeliveredSeq)
}
}
func TestReceipts_ExcludeCallerAndUseMinimum(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, cUser := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
conv, err := r.CreateGroup(ctx, a, "Test group", []string{b, cUser})
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, conv.ID)
for i := 0; i < 3; i++ {
send(t, r, conv.ID, a, "m")
}
if err := r.MarkDelivered(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
// C has received nothing yet.
rc, err := r.Receipts(ctx, conv.ID, a)
if err != nil {
t.Fatal(err)
}
if rc.ReadSeq != 0 || rc.DeliveredSeq != 0 {
t.Errorf("receipts = %+v, want zeros while one member is behind", rc)
}
if err := r.MarkRead(ctx, conv.ID, cUser, 3); err != nil {
t.Fatal(err)
}
rc, _ = r.Receipts(ctx, conv.ID, a)
if rc.ReadSeq != 3 || rc.DeliveredSeq != 3 {
t.Errorf("receipts = %+v, want 3/3 once everyone has read", rc)
}
}
func TestEnsureAIThread_OnePerUserAndSeparateTab(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a := makeUser(t, pg, "A")
first, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, first.ID)
again, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Errorf("second call made another AI thread: %s vs %s", again.ID, first.ID)
}
// The tabs are a kind filter: an AI thread must not appear under Chats.
chats, _ := r.Inbox(ctx, a, []string{model.ConversationDM, model.ConversationGroup}, 10)
if findConv(chats, first.ID) != nil {
t.Error("AI thread leaked into the Chats tab")
}
kautilya, _ := r.Inbox(ctx, a, []string{model.ConversationAI}, 10)
if findConv(kautilya, first.ID) == nil {
t.Error("AI thread missing from the Kautilya tab")
}
}
func findConv(items []model.InboxItem, id string) *model.InboxItem {
for i := range items {
if items[i].ID == id {
return &items[i]
}
}
return nil
}
func seqsOf(msgs []model.Message) []int64 {
out := make([]int64, len(msgs))
for i, m := range msgs {
out[i] = m.Seq
}
return out
}
func bodiesOf(msgs []model.Message) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Body
}
return out
}
+2
View File
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
END AS rank END AS rank
FROM users u FROM users u
WHERE u.id <> $1 WHERE u.id <> $1
-- A suspended account can't be @mentioned into a post.
AND u.banned_at IS NULL
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3) AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY rank, u.name, u.id ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`, LIMIT $4 OFFSET $5`,
+40
View File
@@ -160,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
return p, nil return p, nil
} }
// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted
// ids are simply absent from the map.
//
// Exists so a page of chat messages sharing N posts costs one query instead of
// N — the same reason ImagesFor is batched.
func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) {
out := make(map[string]*model.Post, len(ids))
if len(ids) == 0 {
return out, nil
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL`
rows, err := r.pg.Pool.Query(ctx, q, ids)
if err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
defer rows.Close()
found := make([]string, 0, len(ids))
for rows.Next() {
p := &model.Post{}
if err := scanPost(rows, p); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
out[p.ID] = p
found = append(found, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
imgs, err := r.ImagesFor(ctx, found)
if err != nil {
return nil, err
}
for id, p := range out {
p.Images = imgs[id]
}
return out, nil
}
// ListByHandle returns the profile feed for a user. Unknown handle → // ListByHandle returns the profile feed for a user. Unknown handle →
// empty page (matches pre-existing behaviour). // empty page (matches pre-existing behaviour).
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
+24
View File
@@ -54,6 +54,30 @@ func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushS
// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for // PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for
// a user, deleting older ones — a soft per-user device cap. // a user, deleting older ones — a soft per-user device cap.
// SubscribedUserIDs lists everyone with at least one live push subscription.
//
// Used by broadcasts, which fan out to the whole user base: iterating users
// and asking each for subscriptions would query for the (large) majority who
// have never enabled push.
func (r *PushRepo) SubscribedUserIDs(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT DISTINCT user_id FROM push_subscriptions`)
if err != nil {
return nil, fmt.Errorf("subscribed users: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan subscribed user: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error { func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error {
if keep < 1 { if keep < 1 {
keep = 1 keep = 1
+693
View File
@@ -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
}
+110 -4
View File
@@ -4,12 +4,17 @@ import (
"context" "context"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
) )
// ErrRewardNotFound is an unknown or retired reward key.
var ErrRewardNotFound = errors.New("reward not found")
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the // rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is // repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
// embedded so the BFF can seed/sync the catalog on startup with no external // embedded so the BFF can seed/sync the catalog on startup with no external
@@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er
// reward embedded for direct rendering. // reward embedded for direct rendering.
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) { func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT g.milestone, g.granted_at, SELECT g.milestone, g.granted_at, g.source, g.celebrated_at IS NOT NULL,
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
FROM sadhna_reward_grants g FROM sadhna_reward_grants g
JOIN sadhna_rewards w ON w.id = g.reward_id JOIN sadhna_rewards w ON w.id = g.reward_id
WHERE g.user_id = $1 WHERE g.user_id = $1
ORDER BY g.granted_at DESC, g.milestone DESC`, -- NULLS LAST so gifts, which have no milestone, sort among the rest
-- by date rather than jumping to the top.
ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`,
userID, userID,
) )
if err != nil { if err != nil {
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
for rows.Next() { for rows.Next() {
var g model.SadhnaRewardGrant var g model.SadhnaRewardGrant
if err := rows.Scan( if err := rows.Scan(
&g.Milestone, &g.GrantedAt, &g.Milestone, &g.GrantedAt, &g.Source, &g.Opened,
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
); err != nil { ); err != nil {
@@ -180,8 +187,106 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
return nil return nil
} }
// CreateCustom adds a reward to the catalog from the admin panel.
//
// Custom entries survive re-seeding: Seed upserts only the keys in its
// embedded JSON, and these keys are namespaced away from those.
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
VALUES ($1, $2, $3, $4, true, 1000)
RETURNING id, key, name, meaning, image_url, active, sort`,
key, name, meaning, imageURL,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if err != nil {
return nil, fmt.Errorf("create custom reward: %w", err)
}
return &w, nil
}
// DeleteCustom removes an admin-made reward from the catalog.
//
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
// the artwork onto the message rather than referencing the catalog, so history
// stays intact.
//
// Restricted to custom keys. A seeded reward would come straight back on the
// next Seed run, and deleting one would rip a streak reward out of the
// galleries of everyone who earned it.
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
if err != nil {
return fmt.Errorf("delete custom reward: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRewardNotFound
}
return nil
}
// ByKey looks up one reward from the catalog.
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, meaning, image_url, active, sort
FROM sadhna_rewards WHERE key = $1 AND active`, key,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRewardNotFound
}
if err != nil {
return nil, fmt.Errorf("reward by key: %w", err)
}
return &w, nil
}
// GrantGiftToEveryone hands one reward to every account, unopened.
//
// Idempotent by the partial unique index: re-running adds it only to people
// who don't have it, so a mis-click can't create duplicate tiles. Returns how
// many accounts it reached.
func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, source)
SELECT u.id, $1, 'gift'
FROM users u
WHERE u.banned_at IS NULL
ON CONFLICT DO NOTHING`, rewardID)
if err != nil {
return 0, fmt.Errorf("grant gift: %w", err)
}
return tag.RowsAffected(), nil
}
// OpenGift marks one gift as seen, which is what un-blurs its tile.
//
// Scoped to a single reward rather than clearing everything pending: a devotee
// with two gifts waiting should open them one at a time.
func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE sadhna_reward_grants g
SET celebrated_at = now()
FROM sadhna_rewards w
WHERE w.id = g.reward_id
AND g.user_id = $1 AND w.key = $2
AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey)
if err != nil {
return fmt.Errorf("open gift: %w", err)
}
return nil
}
// PendingGrant returns the most recent grant the client hasn't celebrated // PendingGrant returns the most recent grant the client hasn't celebrated
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti. // yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
//
// STREAK grants only. An admin gift is also uncelebrated until opened, but it
// waits as a blurred tile in the gallery rather than ambushing the devotee
// with confetti the moment they load the page.
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) { func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
var g model.SadhnaRewardGrant var g model.SadhnaRewardGrant
err := r.pg.Pool.QueryRow(ctx, ` err := r.pg.Pool.QueryRow(ctx, `
@@ -189,7 +294,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
FROM sadhna_reward_grants g FROM sadhna_reward_grants g
JOIN sadhna_rewards w ON w.id = g.reward_id JOIN sadhna_rewards w ON w.id = g.reward_id
WHERE g.user_id = $1 AND g.celebrated_at IS NULL WHERE g.user_id = $1 AND g.celebrated_at IS NULL AND g.source = 'streak'
ORDER BY g.granted_at DESC, g.milestone DESC ORDER BY g.granted_at DESC, g.milestone DESC
LIMIT 1`, LIMIT 1`,
userID, userID,
@@ -198,6 +303,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
) )
g.Source = "streak"
if err != nil { if err != nil {
return nil, nil // no rows / nothing pending return nil, nil // no rows / nothing pending
} }
+29
View File
@@ -242,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er
// BlockState reports, in one round-trip, whether a blocked b and whether // BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a. // b blocked a.
// BlockedWith returns the subset of others that viewerID is blocked with in
// EITHER direction — the set whose content must be hidden from them.
//
// The batched counterpart to BlockState, for resolving many authors at once.
func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) {
out := make(map[string]bool, len(others))
if viewerID == "" || len(others) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END
FROM user_blocks
WHERE (blocker_id = $1 AND blocked_id = ANY($2))
OR (blocked_id = $1 AND blocker_id = ANY($2))`,
viewerID, others)
if err != nil {
return nil, fmt.Errorf("blocked with: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan blocked with: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) { func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, ` err = r.pg.Pool.QueryRow(ctx, `
SELECT SELECT
+241
View File
@@ -0,0 +1,241 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrNotStaff = errors.New("not a staff member")
ErrRoleNotFound = errors.New("role not found")
ErrRoleKeyTaken = errors.New("a role with that name already exists")
ErrRoleInUse = errors.New("role is still assigned to staff")
)
// SuperadminKey is the seeded role that holds the wildcard.
const SuperadminKey = "superadmin"
type StaffRepo struct{ pg *Postgres }
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
// Access loads the caller's role and permissions. ErrNotStaff when the account
// holds no admin role at all.
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
var a model.StaffAccess
err := r.pg.Pool.QueryRow(ctx, `
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff access: %w", err)
}
return &a, nil
}
const staffSelect = `
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
ro.id, ro.key, ro.name, u.admin_blocked_at
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id`
func scanStaff(row pgx.Row, m *model.StaffMember) error {
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
}
// ListStaff returns every account holding a role, superadmins first.
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
if err != nil {
return nil, fmt.Errorf("list staff: %w", err)
}
defer rows.Close()
out := []model.StaffMember{}
for rows.Next() {
var m model.StaffMember
if err := scanStaff(rows, &m); err != nil {
return nil, fmt.Errorf("scan staff: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
var m model.StaffMember
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff member: %w", err)
}
return &m, nil
}
// SetRole grants or changes a role. A nil roleID revokes staff access and
// clears any block, so a later re-grant starts clean.
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_role_id = $2,
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
updated_at = now()
WHERE id = $1`, userID, roleID)
if err != nil {
return fmt.Errorf("set staff role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserGone
}
return nil
}
// SetBlocked suspends or restores a staff member's panel access.
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
if err != nil {
return fmt.Errorf("set staff blocked: %w", err)
}
return nil
}
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
// stops the last one from being demoted, revoked or blocked.
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
SuperadminKey).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count superadmins: %w", err)
}
return n, nil
}
// UserIDByHandle resolves a handle for granting a role.
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx,
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserGone
}
if err != nil {
return "", fmt.Errorf("resolve handle: %w", err)
}
return id, nil
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
ro.permissions, ro.created_at,
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
FROM admin_roles ro
ORDER BY ro.is_system DESC,
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
ro.name`)
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
defer rows.Close()
out := []model.AdminRole{}
for rows.Next() {
var ro model.AdminRole
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
return nil, fmt.Errorf("scan role: %w", err)
}
out = append(out, ro)
}
return out, rows.Err()
}
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, description, is_system, permissions, created_at
FROM admin_roles WHERE id = $1`, id,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRoleNotFound
}
if err != nil {
return nil, fmt.Errorf("load role: %w", err)
}
return &ro, nil
}
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO admin_roles (key, name, description, permissions)
VALUES ($1, $2, $3, $4)
RETURNING id, key, name, description, is_system, permissions, created_at`,
key, name, description, perms,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if err != nil {
if isUniqueViolation(err, "admin_roles_key_key") {
return nil, ErrRoleKeyTaken
}
return nil, fmt.Errorf("create role: %w", err)
}
return &ro, nil
}
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
// bug higher up still can't rewrite superadmin.
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE admin_roles
SET name = $2, description = $3, permissions = $4, updated_at = now()
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
// DeleteRole removes an unused CUSTOM role.
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
var members int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
return fmt.Errorf("count role members: %w", err)
}
if members > 0 {
return ErrRoleInUse
}
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
if err != nil {
return fmt.Errorf("delete role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
+2 -1
View File
@@ -39,7 +39,8 @@ type CategoryCount struct {
// the table sizes are modest enough that chasing parallelism here // the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity. // would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) { func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{} // Non-nil so an empty table serialises as [] rather than null.
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour) sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct { scalars := []struct {
+13 -2
View File
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// enough at our user-table scale; revisit alongside the pg_trgm switch. // enough at our user-table scale; revisit alongside the pg_trgm switch.
var total int var total int
if err := r.pg.Pool.QueryRow(ctx, if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`, `SELECT COUNT(*) FROM users
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
substring).Scan(&total); err != nil { substring).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count search users: %w", err) return nil, 0, fmt.Errorf("count search users: %w", err)
} }
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// and name could reshuffle between pages. // and name could reshuffle between pages.
sql := `SELECT ` + userColumns + ` sql := `SELECT ` + userColumns + `
FROM users FROM users
WHERE name ILIKE $1 OR handle::text ILIKE $1 WHERE (name ILIKE $1 OR handle::text ILIKE $1)
-- Suspended accounts are unsearchable. This one query backs the
-- people search page, the mention autocomplete AND both chat member
-- pickers, so a banned account appearing here is offered as someone
-- to @mention or add to a group. ResolveHandles already refuses them
-- on the way in, which without this makes "Add" silently do nothing.
AND banned_at IS NULL
ORDER BY ORDER BY
CASE CASE
WHEN handle::text ILIKE $2 THEN 0 WHEN handle::text ILIKE $2 THEN 0
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
FROM users u FROM users u
WHERE u.created_at >= $2 WHERE u.created_at >= $2
AND u.id <> $1 AND u.id <> $1
-- Suspended accounts are not discoverable. A ban hides someone from
-- the app; surfacing them in the welcome strip would be the most
-- prominent placement on the home page.
AND u.banned_at IS NULL
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM follows f SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id WHERE f.follower_id = $1 AND f.following_id = u.id
+166
View File
@@ -16,6 +16,13 @@ import (
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres // VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
// (vani_of_the_day) while the candidate content is sampled from Mongo (the // (vani_of_the_day) while the candidate content is sampled from Mongo (the
// verses / prose_chapters / transcriptions collections). // verses / prose_chapters / transcriptions collections).
var (
// ErrVaniRefInvalid is a malformed content id.
ErrVaniRefInvalid = errors.New("not a valid content reference")
// ErrVaniRefNotFound is a well-formed id that matches nothing.
ErrVaniRefNotFound = errors.New("that content no longer exists")
)
type VaniRepo struct { type VaniRepo struct {
pg *Postgres pg *Postgres
mongo *MongoDB mongo *MongoDB
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
return nil return nil
} }
// Upsert writes a day's pick, REPLACING any existing one.
//
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
// picker must never overwrite a day already decided. An admin choosing the day
// deliberately is the one case that should win.
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
b, err := json.Marshal(pick.Payload)
if err != nil {
return err
}
_, err = r.pg.Pool.Exec(ctx,
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (day) DO UPDATE
SET kind = EXCLUDED.kind,
ref_id = EXCLUDED.ref_id,
payload = EXCLUDED.payload`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("upsert vani pick: %w", err)
}
return nil
}
// Clear removes a day's pick, so the automatic picker chooses again.
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
if err != nil {
return fmt.Errorf("clear vani pick: %w", err)
}
return nil
}
// PicksFrom lists decided days from `from` onward, for the admin schedule.
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
if err != nil {
return nil, fmt.Errorf("list vani picks: %w", err)
}
defer rows.Close()
out := []model.VaniPick{}
for rows.Next() {
var p model.VaniPick
var raw []byte
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
return nil, fmt.Errorf("scan vani pick: %w", err)
}
if err := json.Unmarshal(raw, &p.Payload); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// BookTitle resolves a book code to its display title (from book_meta.data.title), // BookTitle resolves a book code to its display title (from book_meta.data.title),
// or "" if unknown. Used to label a prose pick with its book name. Decoding into // or "" if unknown. Used to label a prose pick with its book name. Decoding into
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode // a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
@@ -135,6 +200,107 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
return false, cur.Err() return false, cur.Err()
} }
// oneByHex loads a single document by its _id hex. Shared by the three
// admin-pick lookups, which differ only in collection and target type.
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
oid, err := bson.ObjectIDFromHex(hex)
if err != nil {
return ErrVaniRefInvalid
}
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
if errors.Is(err, mongo.ErrNoDocuments) {
return ErrVaniRefNotFound
}
if err != nil {
return fmt.Errorf("load %s %s: %w", coll, hex, err)
}
return nil
}
// TranscriptBySlug loads a lecture by its URL slug.
//
// Search cannot do this: the slug is not an indexed field, so querying
// "720720sb-london" matches on tokens like "london" and returns whatever else
// mentions it. Resolving the slug directly is the only reliable way to pin a
// specific item someone already has the URL for.
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
err := r.mongo.Collection("transcriptions").
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("transcript by slug: %w", err)
}
return &t, nil
}
// ProseBySlug loads a prose chapter by book and slug.
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
{Key: "book", Value: book},
{Key: "slug", Value: slug},
}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("prose by slug: %w", err)
}
return &p, nil
}
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
// which is what a verse URL carries.
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
filter := bson.D{
{Key: "book", Value: book},
{Key: "chapter", Value: chapter},
{Key: "verse", Value: verse},
}
if division != nil && *division != "" {
filter = append(filter, bson.E{Key: "division", Value: *division})
}
var v VerseDoc
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("verse by ref: %w", err)
}
return &v, nil
}
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
// chosen. The payload is then built by the same builders the random picker
// uses, so a pinned pick renders identically to an automatic one.
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
var v VerseDoc
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
return nil, err
}
return &v, nil
}
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
return nil, err
}
return &p, nil
}
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
return nil, err
}
return &t, nil
}
// SampleVerse returns a random verse with a translation, excluding the given // SampleVerse returns a random verse with a translation, excluding the given
// _id hexes. Returns nil when nothing matches. // _id hexes. Returns nil when nothing matches.
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) { func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
+210 -41
View File
@@ -12,6 +12,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config" "github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/handler" "github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/internal/worker" "github.com/prabhupadasays/spsays-bff/internal/worker"
@@ -69,6 +70,7 @@ type Server struct {
relationH *handler.RelationshipHandler relationH *handler.RelationshipHandler
soketiWebhookH *handler.SoketiWebhookHandler soketiWebhookH *handler.SoketiWebhookHandler
broadcastAuthH *handler.BroadcastingAuthHandler broadcastAuthH *handler.BroadcastingAuthHandler
chatH *handler.ChatHandler
notificationH *handler.NotificationHandler notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler pushH *handler.PushHandler
@@ -76,6 +78,10 @@ type Server struct {
feedbackH *handler.FeedbackHandler feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler statsH *handler.StatsHandler
adminH *handler.AdminHandler adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler
staffRepo *repository.StaffRepo
staffH *handler.StaffHandler
quizH *handler.QuizHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503) // Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer authIssuer *auth.Issuer
@@ -193,9 +199,6 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// across pages (nil-safe — falls back to offset paging without Redis). // across pages (nil-safe — falls back to offset paging without Redis).
feedRepo := repository.NewFeedRepo(s.redis, logger) feedRepo := repository.NewFeedRepo(s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo) postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
// Soketi signs webhooks with the app secret; on channel_vacated for a
// per-user notifications channel we evict that user's feed seen-set.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo) bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2) collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -225,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.
@@ -258,17 +265,34 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret, cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger, cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
) )
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny; chat conversation channels get added here.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
)
pushRepo := repository.NewPushRepo(s.pg) pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger) pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo) s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg) notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL) notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
// per-user notifications channel tells us whether they are connected: we
// evict the feed seen-set when they leave, and chat uses the same signal
// to skip pushing to someone who is already in the app. Constructed after
// chatSvc because it depends on it.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(
cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, chatSvc, logger)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
handler.ConversationRule(chatSvc.IsParticipant),
)
// Wire the notification seam into the post service so a new like notifies // Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves). // the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc) postSvc.SetNotifier(notifSvc)
@@ -296,7 +320,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// every 15 minutes (no-op when Redis is unavailable). // every 15 minutes (no-op when Redis is unavailable).
worker.NewLikesWorker(likeRepo, logger).Run(wctx) worker.NewLikesWorker(likeRepo, logger).Run(wctx)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger) graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, chatRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc) s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc) s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc) s.postH = handler.NewPostHandler(postSvc, graphSvc)
@@ -312,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
@@ -328,7 +352,18 @@ 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
// rows: the modal is the surface, and push plus the realtime nudge only
// tell clients to go and check for one.
broadcastSvc := service.NewBroadcastService(
repository.NewBroadcastRepo(s.pg), pushSender, pushRepo,
notifPrefsRepo, rtClient, r2, logger)
s.broadcastH = handler.NewBroadcastHandler(broadcastSvc)
} }
// initShortener wires Postgres + Redis + the shorturl handler/workers. // initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -455,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)
} }
@@ -684,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
@@ -895,6 +953,63 @@ func (s *Server) setupRouter() {
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle) r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
} }
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.broadcastH != nil {
// Announcements: fetch the one waiting, and dismiss it.
r.Route("/broadcasts", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/pending", s.broadcastH.Pending)
r.Post("/{id}/seen", s.broadcastH.Seen)
})
}
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Get("/unread", s.chatH.Unread)
r.Get("/stickers", s.chatH.Stickers)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
r.Post("/link-preview", s.chatH.LinkPreview)
r.Get("/settings", s.chatH.Settings)
r.Patch("/settings", s.chatH.UpdateSettings)
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
r.Route("/conversations/{id}", func(r chi.Router) {
r.Get("/", s.chatH.Detail)
r.Patch("/", s.chatH.UpdateGroup)
r.Post("/members", s.chatH.AddMembers)
r.Delete("/members/{handle}", s.chatH.RemoveMember)
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/typing", s.chatH.Typing)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
r.Post("/attachments/presign", s.chatH.PresignAttachment)
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
r.Get("/avatar", s.chatH.GroupAvatar)
r.Get("/gallery", s.chatH.Gallery)
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Get("/messages/{messageID}", s.chatH.Message)
r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions)
r.Post("/messages/{messageID}/sticker", s.chatH.SaveSticker)
r.Put("/messages/{messageID}/reaction", s.chatH.React)
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
})
})
}
// Pusher channel authorization — pusher-js posts here (as its // Pusher channel authorization — pusher-js posts here (as its
// `authEndpoint`) before subscribing to a private-/presence- channel. // `authEndpoint`) before subscribing to a private-/presence- channel.
// Session-authenticated: the signature is only minted for a caller // Session-authenticated: the signature is only minted for a caller
@@ -1052,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)
@@ -1068,34 +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("/me", s.staffH.Me)
r.Get("/disciples", s.adminH.ListDisciples)
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple) // Staff and roles — superadmins only.
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple) can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
r.Get("/posts", s.adminH.ListPosts) can(rbac.ManageStaff).Get("/staff", s.staffH.List)
r.Post("/posts/{id}/hide", s.adminH.HidePost) can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost) can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
r.Delete("/posts/{id}", s.adminH.DeletePost) can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
r.Post("/posts/{id}/restore", s.adminH.RestorePost) can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
r.Delete("/comments/{id}", s.adminH.DeleteComment) can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
r.Post("/comments/{id}/restore", s.adminH.RestoreComment) can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
r.Get("/collections", s.adminH.ListCollections) can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
r.Get("/feedback", s.adminH.ListFeedback) can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) can("stats:read").Get("/stats", s.adminH.Stats)
r.Get("/reports", s.adminH.ListReports)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) can("users:read").Get("/users", s.adminH.ListUsers)
r.Get("/short-urls", s.adminH.ListShortURLs) can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
r.Get("/short-urls/stats", s.adminH.ShortURLStats) can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
can("users:read").Get("/disciples", s.adminH.ListDisciples)
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
can("vani:read").Get("/vani", s.vaniH.Schedule)
can("vani:write").Post("/vani", s.vaniH.Set)
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
can("collections:read").Get("/collections", s.adminH.ListCollections)
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
can("reports:read").Get("/reports", s.adminH.ListReports)
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
can("quizzes:write").Post("/quizzes", s.quizH.Create)
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
}) })
}) })
+4 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -335,7 +336,8 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
var buf bytes.Buffer var buf bytes.Buffer
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{ if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
"RecipientName": name, "RecipientName": name,
"Reply": message, // Rendered markers; escaped by richtext, newlines become <br>.
"Reply": richtext.ToHTML(message),
"URL": s.frontendURL, "URL": s.frontendURL,
}); err != nil { }); err != nil {
s.logger.Warn("render feedback reply email failed", zap.Error(err)) s.logger.Warn("render feedback reply email failed", zap.Error(err))
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
<p>Thank you for writing to us. Here's our reply to your feedback:</p> <p>Thank you for writing to us. Here's our reply to your feedback:</p>
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote> <blockquote style="border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
<p style="margin:1.25em 0"> <p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a> <a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
+268
View File
@@ -0,0 +1,268 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
var (
ErrBroadcastTitle = errors.New("a title is required")
ErrBroadcastBody = errors.New("a description is required")
ErrBroadcastCTA = errors.New("that CTA link is not a valid URL")
ErrBroadcastBanner = errors.New("that image was not uploaded for a broadcast")
)
const (
maxBroadcastTitleRunes = 200
maxBroadcastBodyRunes = 4000
// bannerPrefix namespaces broadcast banners in the PUBLIC bucket. An
// announcement is not private, and a signed URL would expire while the
// modal sat open on someone's screen.
bannerPrefix = "broadcasts/"
)
// BroadcastChannel is the PUBLIC realtime channel every client watches for
// announcements.
//
// Public is deliberate and safe because the event carries no content — just
// "something was published". Each client then fetches /broadcasts/pending,
// which is authenticated and per-user, so someone who has already dismissed it
// gets nothing. That also keeps publishing to one Soketi call instead of one
// per user.
const BroadcastChannel = "broadcasts"
type BroadcastService struct {
repo *repository.BroadcastRepo
push *PushSender
pushes *repository.PushRepo
prefs *repository.NotificationPrefsRepo
rt *realtime.SoketiClient
r2 *repository.R2
logger *zap.Logger
}
func NewBroadcastService(
repo *repository.BroadcastRepo,
push *PushSender,
pushes *repository.PushRepo,
prefs *repository.NotificationPrefsRepo,
rt *realtime.SoketiClient,
r2 *repository.R2,
logger *zap.Logger,
) *BroadcastService {
return &BroadcastService{
repo: repo, push: push, pushes: pushes, prefs: prefs,
rt: rt, r2: r2, logger: logger,
}
}
// CreateParams is an admin-authored announcement.
type CreateParams struct {
Title string
Body string
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
}
// Create stores a draft. Nothing is delivered until Publish.
func (s *BroadcastService) Create(ctx context.Context, adminID string, p CreateParams) (*model.Broadcast, error) {
title := strings.TrimSpace(p.Title)
body := strings.TrimSpace(p.Body)
if title == "" || len([]rune(title)) > maxBroadcastTitleRunes {
return nil, ErrBroadcastTitle
}
if body == "" || len([]rune(body)) > maxBroadcastBodyRunes {
return nil, ErrBroadcastBody
}
if p.CTAURL != nil && strings.TrimSpace(*p.CTAURL) != "" {
clean, ok := safeCTAURL(*p.CTAURL)
if !ok {
return nil, ErrBroadcastCTA
}
p.CTAURL = &clean
} else {
p.CTAURL = nil
}
if p.BannerKey != nil && !strings.HasPrefix(*p.BannerKey, bannerPrefix) {
return nil, ErrBroadcastBanner
}
// Same parser as the chat composer, so the admin writes *bold* / ~strike~ /
// `code` and links are detected — and no HTML is ever stored.
clean, entities := ParseEntities(body)
encoded, err := json.Marshal(entities)
if err != nil {
return nil, fmt.Errorf("encode entities: %w", err)
}
if entities == nil {
encoded = []byte(`[]`)
}
b, key, err := s.repo.Create(ctx, repository.CreateBroadcastParams{
Title: title, Body: clean, Entities: encoded, BannerKey: p.BannerKey,
CTALabel: p.CTALabel, CTAURL: p.CTAURL, ExpiresAt: p.ExpiresAt,
CreatedBy: adminID,
})
if err != nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// Publish makes a broadcast live, then nudges everyone: connected clients over
// realtime, absent ones by push.
func (s *BroadcastService) Publish(ctx context.Context, id string) (*model.Broadcast, error) {
b, key, err := s.repo.Publish(ctx, id)
if err != nil {
return nil, err
}
s.withBanner(b, key)
// One trigger for the whole user base. Content-free on purpose — clients
// re-fetch their own pending state.
if s.rt != nil {
if err := s.rt.Trigger(ctx, BroadcastChannel, "broadcast_published", map[string]any{}); err != nil {
s.logger.Warn("broadcast: realtime nudge failed", zap.Error(err))
}
}
go s.fanOutPush(context.WithoutCancel(ctx), b)
return b, nil
}
// fanOutPush notifies everyone with a push subscription who hasn't opted out
// of announcements.
//
// Runs detached from the request: a large user base takes far longer than an
// admin should wait on a button.
func (s *BroadcastService) fanOutPush(ctx context.Context, b *model.Broadcast) {
if s.push == nil || !s.push.Enabled() || s.pushes == nil {
return
}
users, err := s.pushes.SubscribedUserIDs(ctx)
if err != nil {
s.logger.Warn("broadcast: listing subscribers failed", zap.Error(err))
return
}
sent := 0
for _, uid := range users {
if s.prefs != nil {
// Reuses the existing "Community" category, described in settings
// as rare announcements — which is exactly this. Someone who
// opted out of those stays opted out.
if off, err := s.prefs.DisabledSet(ctx, uid); err == nil &&
prefOff(off, NotifDiscipleJoined, repository.ChannelPush) {
continue
}
}
s.push.SendToUser(ctx, uid, PushPayload{
Title: b.Title,
Body: truncateRunes(b.Body, 160),
URL: "/",
// One tag per broadcast: a re-publish replaces rather than stacks.
Tag: "broadcast:" + b.ID,
})
sent++
}
s.logger.Info("broadcast: push fan-out complete",
zap.String("broadcast_id", b.ID), zap.Int("recipients", sent))
}
// Pending returns the announcement waiting for this user, or nil.
func (s *BroadcastService) Pending(ctx context.Context, userID string) (*model.Broadcast, error) {
b, key, err := s.repo.Pending(ctx, userID)
if err != nil || b == nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// MarkSeen records that a user dismissed it — the "at most once" half.
func (s *BroadcastService) MarkSeen(ctx context.Context, id, userID string) error {
return s.repo.MarkSeen(ctx, id, userID)
}
func (s *BroadcastService) List(ctx context.Context, limit int) ([]model.Broadcast, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
items, keys, err := s.repo.List(ctx, limit)
if err != nil {
return nil, err
}
for i := range items {
s.withBanner(&items[i], keys[i])
}
return items, nil
}
func (s *BroadcastService) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
// PresignBanner issues an upload URL for a banner image.
func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string) (uploadURL, key string, err error) {
if s.r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
kind, ok := attachmentKindFor(contentType)
if !ok || kind != model.AttachmentImage {
return "", "", ErrChatBadMedia
}
// Returns the KEY: broadcasts.banner_key stores a key, and withBanner
// builds the URL from it at read time.
return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
}
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
if key == nil || *key == "" || s.r2 == nil {
return
}
url := s.r2.PublicURL(*key)
b.BannerURL = &url
}
// safeCTAURL accepts an in-app path or an absolute http(s) URL.
//
// Admin-authored, so this is a typo guard more than a threat model — but
// `javascript:` and `data:` still have no business in a link the whole user
// base is invited to tap.
func safeCTAURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 2048 {
return "", false
}
if strings.ContainsAny(raw, "\\") || strings.ContainsAny(raw, "\n\r\t") {
return "", false
}
if strings.HasPrefix(raw, "/") {
// Protocol-relative "//evil.example" is not an in-app path.
if strings.HasPrefix(raw, "//") {
return "", false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" {
return "", false
}
return raw, true
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", false
}
return u.String(), true
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
func testBroadcastService(t *testing.T) (*BroadcastService, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
// Nil push/realtime/R2: these tests are about who sees what, and none of
// those services are reachable from a test run.
return NewBroadcastService(repository.NewBroadcastRepo(pg), nil, nil, nil, nil, nil, zap.NewNop()), pg
}
func bcUser(t *testing.T, pg *repository.Postgres, label string) string {
t.Helper()
id := uuid.NewString()
suffix := id[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, "bc-"+suffix+"@example.invalid", "BC "+label, "bc_"+suffix); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
func cleanupBroadcast(t *testing.T, pg *repository.Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM broadcasts WHERE id = $1`, id)
})
}
// "At least and at most once" is the whole contract. A user must be offered a
// published announcement until they dismiss it, and never again after.
func TestBroadcast_ShownExactlyOncePerUser(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, alice, bob := bcUser(t, pg, "Admin"), bcUser(t, pg, "A"), bcUser(t, pg, "B")
b, err := svc.Create(ctx, admin, CreateParams{
Title: "New: chat", Body: "Direct messages are here.",
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
// A draft is nobody's business yet.
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Fatalf("an unpublished draft was offered: %+v", got)
}
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
// At least once: offered, and still offered until dismissed.
for i := 0; i < 2; i++ {
got, err := svc.Pending(ctx, alice)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != b.ID {
t.Fatalf("call %d: expected the published announcement, got %+v", i+1, got)
}
}
// At most once: dismissed means gone, and dismissing twice is harmless.
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Fatal(err)
}
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Errorf("second dismissal should be idempotent, got %v", err)
}
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Errorf("shown again after dismissal: %+v", got)
}
// One person dismissing must not dismiss it for everyone.
if got, _ := svc.Pending(ctx, bob); got == nil || got.ID != b.ID {
t.Errorf("another user lost the announcement: %+v", got)
}
}
// An announcement past its expiry stops being offered, so someone signing up
// months later isn't shown last quarter's news.
func TestBroadcast_ExpiryStopsDelivery(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
past := time.Now().Add(-time.Hour)
b, err := svc.Create(ctx, admin, CreateParams{
Title: "Old news", Body: "Yesterday's announcement.", ExpiresAt: &past,
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
if got, _ := svc.Pending(ctx, reader); got != nil {
t.Errorf("an expired announcement was offered: %+v", got)
}
}
// Only the newest announcement is offered: two modals stacked on launch is a
// worse experience than missing the older one.
func TestBroadcast_OffersNewestFirst(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
older, err := svc.Create(ctx, admin, CreateParams{Title: "First", Body: "one"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, older.ID)
if _, err := svc.Publish(ctx, older.ID); err != nil {
t.Fatal(err)
}
newer, err := svc.Create(ctx, admin, CreateParams{Title: "Second", Body: "two"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, newer.ID)
if _, err := svc.Publish(ctx, newer.ID); err != nil {
t.Fatal(err)
}
got, err := svc.Pending(ctx, reader)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != newer.ID {
t.Errorf("expected the newest announcement, got %+v", got)
}
}
+208
View File
@@ -0,0 +1,208 @@
package service
import (
"regexp"
"strings"
"unicode"
)
// Message formatting.
//
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
// of spans describing what to decorate. Two reasons: rendering spans can't
// inject HTML no matter what a user types, and every client (web today, native
// later) applies them natively instead of parsing a markup dialect.
//
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
// up if we count the same way it does. This matters constantly here: Devanāgarī
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
// EntityType values understood by the renderer.
const (
EntityBold = "bold"
EntityStrike = "strike"
EntityCode = "code"
EntityURL = "url"
)
// Entity marks a formatted span of a message body.
type Entity struct {
Type string `json:"type"`
Offset int `json:"offset"` // in UTF-16 code units
Length int `json:"length"` // in UTF-16 code units
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
// text becomes a usable "https://www.x.org" without the client guessing.
URL string `json:"url,omitempty"`
}
// markers maps the opening/closing rune to the entity it produces. Code is
// handled with the same table but its contents are treated as literal.
var markers = map[rune]string{
'*': EntityBold,
'~': EntityStrike,
'`': EntityCode,
}
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
// trailing punctuation that is far more likely to be sentence punctuation than
// part of the URL.
const urlTrailing = `.,;:!?'"`
// ParseEntities converts raw composer text into clean body text plus the spans
// describing its formatting. Markers are removed from the body — what the
// reader copies is the text, not the syntax.
func ParseEntities(raw string) (string, []Entity) {
var b strings.Builder
entities, _ := parseInto(&b, raw, 0)
body := b.String()
// Links are detected last, over the finished body, so offsets need no
// adjustment. Code spans are skipped: a URL inside `backticks` is being
// shown, not linked.
entities = append(entities, detectURLs(body, entities)...)
sortEntities(entities)
return body, entities
}
// parseInto appends the cleaned text of raw to b and returns its entities plus
// how many UTF-16 units were written. Returning the written count (rather than
// deriving it from the input) keeps offsets correct however many markers a
// nested parse strips.
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
runes := []rune(raw)
for i := 0; i < len(runes); i++ {
r := runes[i]
kind, isMarker := markers[r]
if !isMarker {
b.WriteRune(r)
written += utf16Len(r)
continue
}
close := findClose(runes, i)
if close < 0 {
// An unmatched marker is just a character the user typed.
b.WriteRune(r)
written += utf16Len(r)
continue
}
inner := string(runes[i+1 : close])
start := base + written
if kind == EntityCode {
// Code is literal: markers inside it are content, not syntax.
b.WriteString(inner)
written += utf16LenString(inner)
} else {
// Recurse so *bold with `code` inside* works.
nested, n := parseInto(b, inner, start)
entities = append(entities, nested...)
written += n
}
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
i = close
}
return entities, written
}
// findClose returns the index of the marker closing the one at open, or -1.
//
// The rules mirror what chat users expect: an opener must start a word and be
// followed by content, a closer must end one. Without them "2*3*4" and
// "a_b_c"-style text would silently turn into formatting.
func findClose(runes []rune, open int) int {
marker := runes[open]
if open > 0 && isWordRune(runes[open-1]) {
return -1 // mid-word: not an opener
}
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
return -1 // empty or space-led content
}
for i := open + 1; i < len(runes); i++ {
if runes[i] != marker {
continue
}
if unicode.IsSpace(runes[i-1]) {
continue // closer must follow content
}
if i+1 < len(runes) && isWordRune(runes[i+1]) {
continue // mid-word: not a closer
}
return i
}
return -1
}
// detectURLs finds links in body, skipping anything already inside a code span.
func detectURLs(body string, existing []Entity) []Entity {
var out []Entity
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
if text == "" {
continue
}
offset := utf16LenString(body[:loc[0]])
length := utf16LenString(text)
if insideCode(existing, offset) {
continue
}
href := text
if !strings.Contains(href, "://") {
href = "https://" + href
}
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
}
return out
}
func insideCode(entities []Entity, offset int) bool {
for _, e := range entities {
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
return true
}
}
return false
}
// sortEntities orders by offset, then by longer span first, so a renderer can
// open outer decorations before the ones nested inside them.
func sortEntities(entities []Entity) {
for i := 1; i < len(entities); i++ {
for j := i; j > 0; j-- {
a, b := entities[j-1], entities[j]
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
break
}
entities[j-1], entities[j] = b, a
}
}
}
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
func utf16Len(r rune) int {
if r > 0xFFFF {
return 2
}
return 1
}
func utf16LenString(s string) int {
n := 0
for _, r := range s {
n += utf16Len(r)
}
return n
}
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"encoding/json"
"testing"
"unicode/utf16"
)
// utf16Slice extracts a span the way a JavaScript client would, so the tests
// assert the offsets are usable by the actual renderer rather than merely
// self-consistent.
func utf16Slice(s string, offset, length int) string {
u := utf16.Encode([]rune(s))
if offset < 0 || offset+length > len(u) {
return "<out of range>"
}
return string(utf16.Decode(u[offset : offset+length]))
}
func TestParseEntities_Markers(t *testing.T) {
tests := []struct {
name string
raw string
wantBody string
wantSpan map[string]string // entity type -> the text it should cover
}{
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if body != tc.wantBody {
t.Fatalf("body = %q, want %q", body, tc.wantBody)
}
for kind, want := range tc.wantSpan {
found := false
for _, e := range entities {
if e.Type == kind {
if got := utf16Slice(body, e.Offset, e.Length); got != want {
t.Errorf("%s covers %q, want %q", kind, got, want)
}
found = true
}
}
if !found {
t.Errorf("no %s entity in %+v", kind, entities)
}
}
})
}
}
// Offsets are UTF-16 code units because the client slices JS strings with them.
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
// rune-based offset would silently mis-highlight in exactly this app's content.
func TestParseEntities_UTF16Offsets(t *testing.T) {
tests := []struct{ name, raw, want string }{
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if len(entities) == 0 {
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
}
e := entities[len(entities)-1]
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
}
})
}
}
// Text that merely contains marker characters must survive untouched.
func TestParseEntities_NotFormatting(t *testing.T) {
for _, raw := range []string{
"2*3*4", // mid-word asterisks
"a~b~c", // mid-word tildes
"* not bold", // space after opener
"unmatched *marker", // no closer
"**", // empty
"5 * 3 = 15", // spaced operators
} {
body, entities := ParseEntities(raw)
if body != raw {
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
}
for _, e := range entities {
if e.Type != EntityURL {
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
}
}
}
}
func TestParseEntities_Nesting(t *testing.T) {
body, entities := ParseEntities("*bold with `code` inside*")
const wantBody = "bold with code inside"
if body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
var bold, code *Entity
for i := range entities {
switch entities[i].Type {
case EntityBold:
bold = &entities[i]
case EntityCode:
code = &entities[i]
}
}
if bold == nil || code == nil {
t.Fatalf("want both bold and code, got %+v", entities)
}
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
t.Errorf("bold covers %q, want the whole body", got)
}
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
t.Errorf("code covers %q, want %q", got, "code")
}
// Outer span must sort first so a renderer opens it before the nested one.
if entities[0].Type != EntityBold {
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
}
}
func TestParseEntities_URLs(t *testing.T) {
tests := []struct {
name string
raw string
wantText string
wantHref string
}{
{"https", "see https://prabhupadasays.org/bg/2/13 here",
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
{"trailing sentence punctuation excluded", "read https://example.org/a.",
"https://example.org/a", "https://example.org/a"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
var url *Entity
for i := range entities {
if entities[i].Type == EntityURL {
url = &entities[i]
}
}
if url == nil {
t.Fatalf("no url entity in %+v", entities)
}
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
t.Errorf("covers %q, want %q", got, tc.wantText)
}
if url.URL != tc.wantHref {
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
}
})
}
}
// A URL inside backticks is being shown, not linked.
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
_, entities := ParseEntities("run `curl https://example.org` first")
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("linked a URL inside a code span: %+v", e)
}
}
}
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
// keeps scripture references from turning blue.
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
_, entities := ParseEntities(raw)
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
}
}
}
}
// entities is stored as jsonb; empty must round-trip as [] rather than null so
// the column default and the parser agree.
func TestParseEntities_JSONShape(t *testing.T) {
_, entities := ParseEntities("plain text")
if entities == nil {
entities = []Entity{}
}
b, err := json.Marshal(entities)
if err != nil {
t.Fatal(err)
}
if string(b) != "[]" {
t.Errorf("marshalled %s, want []", b)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return repository.NewRewardRepo(pg), pg
}
func giftUser(t *testing.T, pg *repository.Postgres) string {
t.Helper()
id := uuid.NewString()
suffix := id[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', 'Gift User', $3)`,
id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
func giftReward(t *testing.T, pg *repository.Postgres) string {
t.Helper()
key := "gift_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil {
t.Fatalf("create reward: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key)
})
return key
}
// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting
// must not duplicate it.
func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
key := giftReward(t, pg)
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
find := func() (found bool, opened bool) {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
for _, g := range grants {
if g.Reward.Key == key {
if g.Source != "gift" {
t.Errorf("source = %q, want gift", g.Source)
}
if g.Milestone != nil {
t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone)
}
return true, g.Opened
}
}
return false, false
}
found, opened := find()
if !found {
t.Fatal("the gift did not reach an existing account")
}
if opened {
t.Error("a new gift should arrive wrapped")
}
// It must NOT hijack the streak confetti — that stays for streak rewards.
if pending, _ := rewards.PendingGrant(ctx, user); pending != nil {
t.Errorf("a gift appeared as a pending streak celebration: %+v", pending)
}
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Fatal(err)
}
if _, opened = find(); !opened {
t.Error("opening the gift did not un-blur it")
}
// Opening twice is harmless.
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Errorf("second open should be a no-op: %v", err)
}
// Re-gifting tops up newcomers without duplicating anyone's tile.
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
grants, _ := rewards.GrantsByUser(ctx, user)
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
if n != 1 {
t.Errorf("holdings of the gift = %d, want 1", n)
}
// ...and it must not silently re-wrap what was already opened.
if _, opened = find(); !opened {
t.Error("re-gifting re-wrapped an already opened gift")
}
}
// Deleting a custom gift must clear it from every gallery it reached, and must
// refuse to touch the seeded catalog — a seeded reward would return on the
// next Seed run, after having been ripped out of people's galleries.
func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
// A custom reward, as the admin panel creates them.
key := "custom_probe_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(ctx, `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Probe Gift', 'https://example.invalid/p.png')`, key); err != nil {
t.Fatal(err)
}
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
held := func() int {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
return n
}
if held() != 1 {
t.Fatalf("setup: the gift did not reach the gallery")
}
if err := rewards.DeleteCustom(ctx, key); err != nil {
t.Fatal(err)
}
if held() != 0 {
t.Error("deleting the reward left it in a devotee's gallery")
}
if _, err := rewards.ByKey(ctx, key); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("reward still in the catalog: %v", err)
}
// Seeded rewards are off limits.
var seeded string
if err := pg.Pool.QueryRow(ctx,
`SELECT key FROM sadhna_rewards WHERE key NOT LIKE 'custom\_%' LIMIT 1`,
).Scan(&seeded); err != nil {
t.Skip("no seeded rewards to test against")
}
if err := rewards.DeleteCustom(ctx, seeded); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("a seeded reward was deletable: %v", err)
}
if _, err := rewards.ByKey(ctx, seeded); err != nil {
t.Errorf("the seeded reward was removed: %v", err)
}
}
// A suspended account must not be discoverable. One query backs the people
// search page, the mention autocomplete and the chat member pickers, so a leak
// here surfaces in all three.
func TestBannedUsers_AreNotDiscoverable(t *testing.T) {
_, pg := testRewardRepo(t)
ctx := context.Background()
users := repository.NewUserRepo(pg)
// A distinctive name so the search can't match anyone else.
marker := "Zzbanprobe" + uuid.NewString()[:6]
active := giftUser(t, pg)
banned := giftUser(t, pg)
for id, name := range map[string]string{active: marker + " Active", banned: marker + " Banned"} {
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET name = $2 WHERE id = $1`, id, name); err != nil {
t.Fatal(err)
}
}
if _, err := pg.Pool.Exec(ctx,
`UPDATE users SET banned_at = now() WHERE id = $1`, banned); err != nil {
t.Fatal(err)
}
found, total, err := users.Search(ctx, marker, 20, 0)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, u := range found {
ids[u.ID] = true
}
if !ids[active] {
t.Error("the active account is missing from search")
}
if ids[banned] {
t.Error("a suspended account is searchable — it can be @mentioned or added to a group")
}
// The count backs pagination, so it has to agree with the page.
if total != len(found) {
t.Errorf("total = %d but page returned %d — count and page WHERE disagree", total, len(found))
}
// And the welcome strip, which is the most prominent placement there is.
recent, err := users.RecentlyJoined(ctx, active, time.Now().Add(-24*time.Hour), 20)
if err != nil {
t.Fatal(err)
}
for _, u := range recent {
if u.ID == banned {
t.Error("a suspended account appears in the new-users strip")
}
}
}
+15 -4
View File
@@ -26,6 +26,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -44,6 +45,11 @@ const (
// disciples. The actor is the disciple, so it renders and links exactly // disciples. The actor is the disciple, so it renders and links exactly
// like the other actor-driven kinds. // like the other actor-driven kinds.
NotifDiscipleJoined = "disciple_joined" NotifDiscipleJoined = "disciple_joined"
// NotifChatMessage exists ONLY as a preference key. Chat never creates a
// notifications row and never appears in the tray — a new message
// highlights its inbox row instead. The kind is here so the settings
// matrix can offer a push opt-out alongside everything else.
NotifChatMessage = "chat_message"
// Reminder kinds are delivered live (email + push + a websocket toast) // Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history. // but NOT persisted — they're ephemeral nudges, not history.
@@ -60,6 +66,9 @@ 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, plus email for FIRST contact only — see ChatService.emailFirstContact.
// In-app delivery for chat is the inbox itself, so it isn't offered here.
"messages": {NotifChatMessage},
} }
// PrefChannels is every delivery channel a matrix cell can address. // PrefChannels is every delivery channel a matrix cell can address.
@@ -292,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) { 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,
@@ -634,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))
@@ -662,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))
@@ -874,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<di
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}} {{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>
+386
View File
@@ -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)
}
+302
View File
@@ -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)
}
}
}
+94 -1
View File
@@ -16,7 +16,12 @@ package service
import ( import (
"context" "context"
crand "crypto/rand"
"encoding/hex"
"errors"
"math/rand" "math/rand"
"regexp"
"strings"
"time" "time"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
@@ -64,6 +69,9 @@ type SadhnaService struct {
posts *repository.PostRepo posts *repository.PostRepo
users *repository.UserRepo users *repository.UserRepo
rewards *repository.RewardRepo rewards *repository.RewardRepo
// r2 stores custom gift artwork. Optional: nil disables custom gifts
// rather than failing anything that already works.
r2 *repository.R2
logger *zap.Logger logger *zap.Logger
} }
@@ -72,9 +80,13 @@ func NewSadhnaService(
posts *repository.PostRepo, posts *repository.PostRepo,
users *repository.UserRepo, users *repository.UserRepo,
rewards *repository.RewardRepo, rewards *repository.RewardRepo,
r2 *repository.R2,
logger *zap.Logger, logger *zap.Logger,
) *SadhnaService { ) *SadhnaService {
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger} return &SadhnaService{
logs: logs, posts: posts, users: users, rewards: rewards,
r2: r2, logger: logger,
}
} }
const dayFmt = "2006-01-02" const dayFmt = "2006-01-02"
@@ -398,6 +410,87 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad
// AckRewards clears the user's pending celebration(s) after the client has // AckRewards clears the user's pending celebration(s) after the client has
// shown the confetti. // shown the confetti.
// OpenGift un-blurs one gift after the devotee has seen its celebration.
//
// Per gift, not per user: someone with two waiting should open them one at a
// time rather than have the first dismissal clear both.
func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error {
return s.rewards.OpenGift(ctx, userID, rewardKey)
}
// GiftToEveryone hands a reward to every account. Returns how many it reached.
func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) {
reward, err := s.rewards.ByKey(ctx, rewardKey)
if err != nil {
return 0, err
}
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
}
// customKeyPrefix namespaces admin-made rewards away from the seeded catalog,
// so Seed can never collide with one.
const customKeyPrefix = "custom_"
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// customRewardKey derives a stable, unique key from a name.
//
// The random suffix is what makes it unique: two gifts may reasonably share a
// name, and a collision would overwrite the first one's artwork.
func customRewardKey(name string) (string, error) {
slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_")
if slug == "" {
slug = "gift"
}
if len(slug) > 32 {
slug = slug[:32]
}
suffix := make([]byte, 4)
if _, err := crand.Read(suffix); err != nil {
return "", err
}
return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil
}
// CreateCustomGift adds a new sticker to the catalog. It is not granted to
// anyone until it is sent.
func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("a gift needs a name")
}
if strings.TrimSpace(imageURL) == "" {
return nil, errors.New("a gift needs an image")
}
key, err := customRewardKey(name)
if err != nil {
return nil, err
}
return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL)
}
// PresignGiftImage issues an upload URL for custom sticker artwork.
//
// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an
// absolute address, and stickers render for every participant in a chat, so
// the artwork belongs in the public bucket alongside avatars and post images.
func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType)
}
// DeleteCustomGift removes a custom sticker everywhere it is held.
func (s *SadhnaService) DeleteCustomGift(ctx context.Context, key string) error {
if !strings.HasPrefix(key, customKeyPrefix) {
return errors.New("only custom gifts can be deleted")
}
return s.rewards.DeleteCustom(ctx, key)
}
// GiftableRewards is the catalog an admin picks from.
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
return s.rewards.ListGiftable(ctx)
}
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error { func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
return s.rewards.MarkCelebrated(ctx, userID) return s.rewards.MarkCelebrated(ctx, userID)
} }
+33 -1
View File
@@ -71,6 +71,9 @@ type SocialGraphService struct {
reports *repository.ReportRepo reports *repository.ReportRepo
posts *repository.PostRepo posts *repository.PostRepo
users *repository.UserRepo users *repository.UserRepo
// chat answers "may the viewer open a DM with this profile?" — the same
// rule OpenDM enforces, so the button and the action can't disagree.
chat *repository.ChatRepo
notif *NotificationService notif *NotificationService
mail mailer.Mailer mail mailer.Mailer
admins []string admins []string
@@ -83,6 +86,7 @@ func NewSocialGraphService(
reports *repository.ReportRepo, reports *repository.ReportRepo,
posts *repository.PostRepo, posts *repository.PostRepo,
users *repository.UserRepo, users *repository.UserRepo,
chat *repository.ChatRepo,
notif *NotificationService, notif *NotificationService,
mail mailer.Mailer, mail mailer.Mailer,
admins []string, admins []string,
@@ -90,7 +94,8 @@ func NewSocialGraphService(
) *SocialGraphService { ) *SocialGraphService {
return &SocialGraphService{ return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts, follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger, users: users, chat: chat, notif: notif, mail: mail, admins: admins,
logger: logger,
} }
} }
@@ -294,6 +299,33 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
} }
out.Blocked = viewerBlocked out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget out.BlockedBy = blockedByTarget
// A block closes messaging in either direction; otherwise the target's own
// "who can message you" rule decides. Asking the chat repository — rather
// than reimplementing the rule here — is what keeps the offered button and
// the eventual OpenDM in agreement.
switch {
case viewerID == "" || viewerID == targetID:
out.CanMessage = false
case viewerBlocked || blockedByTarget:
out.CanMessage = false
out.MessageRestriction = "blocked"
case s.chat == nil:
out.CanMessage = true
default:
allowed, err := s.chat.CanStartConversation(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.CanMessage = allowed
if !allowed {
settings, err := s.chat.Settings(ctx, targetID)
if err != nil {
return nil, err
}
out.MessageRestriction = settings.WhoCanMessage
}
}
return out, nil return out, nil
} }
+23
View File
@@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp
// presignImage builds an R2 presign for a user-scoped media key under the // presignImage builds an R2 presign for a user-scoped media key under the
// given prefix. The random suffix invalidates browser caches when the image // given prefix. The random suffix invalidates browser caches when the image
// is replaced. // is replaced.
// presignImageKey is presignImage's sibling for callers that store the storage
// KEY rather than a URL — so the CDN domain can change without rewriting rows.
// Handing those callers a public URL silently doubles it at read time.
func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) {
if r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
ext, ok := imageExtensionFor(contentType)
if !ok {
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", "", err
}
key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext)
uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute)
if err != nil {
return "", "", err
}
return uploadURL, key, nil
}
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) { func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
if r2 == nil { if r2 == nil {
return "", "", errors.New("media uploads are not configured") return "", "", errors.New("media uploads are not configured")
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"bytes"
"html/template"
"strings"
"go.uber.org/zap"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
// isn't configured (local development leaves it empty).
const defaultAdminURL = "https://admin.prabhupadasays.org"
// sendStaffWelcome tells someone they've been given admin access, and as
// what. Fire-and-forget, like the other notification emails: the grant is
// already committed, so a mail failure is logged, never surfaced.
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
if s.mail == nil {
return
}
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
s.logger.Info("staff granted; no email on file, skipping welcome",
zap.String("user_id", m.UserID))
return
}
var buf bytes.Buffer
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": strings.TrimSpace(m.Name),
"RoleName": m.RoleName,
"AdminURL": s.adminURL,
}); err != nil {
s.logger.Warn("render staff welcome email failed", zap.Error(err))
return
}
to := strings.TrimSpace(*m.Email)
go func() {
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("staff welcome email send failed",
zap.String("user_id", m.UserID), zap.Error(err))
}
}()
}
// staffWelcomeEmailTpl — same styling as the other account emails. The role
// name is admin-entered (custom roles) and auto-escaped by html/template.
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
<p>Log in with your email and password at
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
<p style="margin:1.25em 0">
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
</p>
<p>For any queries, contact your co-ordinator.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+256
View File
@@ -0,0 +1,256 @@
package service
import (
"context"
"errors"
"regexp"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
var (
ErrStaffSelf = errors.New("you can't change your own staff access")
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
ErrRoleBadPermission = errors.New("unknown permission")
ErrRoleName = errors.New("a role needs a name")
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
)
// StaffService manages who can use the admin panel and with what powers.
// Every method here is reachable only with rbac.ManageStaff — superadmins.
type StaffService struct {
staff *repository.StaffRepo
// mail tells newly added staff about their access; nil sends nothing.
mail mailer.Mailer
logger *zap.Logger
adminURL string
}
// NewStaffService wires staff management. adminURL is the panel's address for
// the welcome email; empty falls back to the production panel.
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
if adminURL == "" {
adminURL = defaultAdminURL
}
if logger == nil {
logger = zap.NewNop()
}
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
}
// Me describes the caller, for the panel to decide what to show.
type Me struct {
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
Permissions []string `json:"permissions"`
}
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
return s.staff.ListStaff(ctx)
}
// isSuperadminRole reports whether a role id is the seeded superadmin.
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
ro, err := s.staff.Role(ctx, roleID)
if err != nil {
return false, err
}
return ro.Key == repository.SuperadminKey, nil
}
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
// out of that state if they're the last one — demoting, revoking or blocking
// them would leave no one able to manage staff, with no way back from the UI.
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
return nil
}
n, err := s.staff.ActiveSuperadmins(ctx)
if err != nil {
return err
}
if n <= 1 {
return ErrLastSuperadmin
}
return nil
}
// Grant gives an account a role, by handle.
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
if err != nil {
return nil, err
}
if userID == actorID {
return nil, ErrStaffSelf
}
if _, err := s.staff.Role(ctx, roleID); err != nil {
return nil, err
}
// Granting to someone who is already staff is a role change, and must
// pass the same last-superadmin check.
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
}
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
return nil, err
}
m, err := s.staff.StaffMember(ctx, userID)
if err != nil {
return nil, err
}
// Only a NEW staff member is welcomed; role changes above stay quiet.
s.sendStaffWelcome(m)
return m, nil
}
// ChangeRole moves a staff member to another role.
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
toSuper, err := s.isSuperadminRole(ctx, roleID)
if err != nil {
return nil, err
}
if !toSuper {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// Revoke removes a staff member's access entirely.
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
if targetID == actorID {
return ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return err
}
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return err
}
return s.staff.SetRole(ctx, targetID, nil)
}
// SetBlocked suspends or restores a staff member's panel access, keeping their
// role so restoring them is one action rather than a re-grant.
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
if blocked {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
return s.staff.ListRoles(ctx)
}
// cleanPermissions validates a custom role's permissions against the catalog.
// The wildcard and admins:manage aren't in the catalog, so they are refused
// here — a custom role can never become a second superadmin.
func cleanPermissions(perms []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
if !rbac.Valid(p) {
return nil, ErrRoleBadPermission
}
seen[p] = true
out = append(out, p)
}
if len(out) == 0 {
return nil, ErrRoleNoPermissions
}
return out, nil
}
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
func roleKeyFor(name string) string {
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
if len(k) > 40 {
k = k[:40]
}
return k
}
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
name = strings.TrimSpace(name)
key := roleKeyFor(name)
if name == "" || key == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
}
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return nil, err
}
if ro.IsSystem {
return nil, ErrRoleSystem
}
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
return nil, err
}
return s.staff.Role(ctx, id)
}
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return err
}
if ro.IsSystem {
return ErrRoleSystem
}
return s.staff.DeleteRole(ctx, id)
}
+181
View File
@@ -0,0 +1,181 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
t.Helper()
var id string
if err := pg.Pool.QueryRow(context.Background(),
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
t.Fatalf("role %s: %v", key, err)
}
return id
}
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
t.Helper()
id := chatUser(t, pg, roleKey)
if _, err := pg.Pool.Exec(context.Background(),
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
t.Fatal(err)
}
return id
}
// The floor: removing the last active superadmin would leave nobody able to
// manage staff, with no way back from the UI.
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
staff := repository.NewStaffRepo(pg)
svc := NewStaffService(staff, nil, nil, "")
// Isolate from any staff already in the dev database: the floor counts
// superadmins globally. Parked in a REAL table — a TEMP table is
// per-connection, and the pool may run the cleanup on a different one,
// which would leave real staff stripped of their roles.
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
t.Fatal(err)
}
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
t.Errorf("restoring parked staff: %v", err)
return
}
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
})
a := makeStaff(t, pg, "superadmin")
b := makeStaff(t, pg, "superadmin")
mod := makeStaff(t, pg, "moderator")
// Nobody acts on themselves.
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
}
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
t.Errorf("self-block: %v, want ErrStaffSelf", err)
}
// A superadmin can block another admin/mod — and another superadmin while
// one remains.
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
t.Errorf("block moderator: %v", err)
}
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
t.Errorf("block second superadmin: %v", err)
}
// Now a is the only active one: a blocked b can't be used to demote a.
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
}
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
}
// A blocked staff member is turned away at the door.
acc, err := staff.Access(ctx, mod)
if err != nil || !acc.Blocked {
t.Errorf("blocked moderator access = %+v, %v", acc, err)
}
}
// A custom role is built only from the catalog — never the wildcard or
// admins:manage, which would make it a second superadmin.
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
}
}
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
set := rbac.NewSet(ro.Permissions)
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
t.Errorf("custom role permissions resolved wrong: %v", set.List())
}
// System roles are read-only.
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
}
}
// captureMailer records sends; the welcome email goes out on a goroutine.
type captureMailer struct{ sent chan [3]string }
func (m *captureMailer) Send(to, subject, body string) error {
m.sent <- [3]string{to, subject, body}
return nil
}
// Adding someone as staff emails them their role and where to log in; moving
// an existing staff member to another role does not.
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
mail := &captureMailer{sent: make(chan [3]string, 4)}
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
actor := chatUser(t, pg, "Actor")
target, handle := chatUserWithHandle(t, pg, "Target")
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
})
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
t.Fatal(err)
}
select {
case got := <-mail.sent:
if !strings.HasPrefix(got[0], "chatsvc-") {
t.Errorf("sent to %q, want the target's address", got[0])
}
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
if !strings.Contains(got[2], want) {
t.Errorf("email body missing %q", want)
}
}
if !strings.Contains(got[1], "Moderator") {
t.Errorf("subject %q doesn't name the role", got[1])
}
case <-time.After(3 * time.Second):
t.Fatal("no welcome email was sent")
}
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
t.Fatal(err)
}
select {
case got := <-mail.sent:
t.Errorf("a role change sent an email: %q", got[1])
case <-time.After(300 * time.Millisecond):
}
}
+140
View File
@@ -6,9 +6,12 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -31,6 +34,13 @@ func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService
// Today returns the current day's pick, choosing (and persisting) one on the // Today returns the current day's pick, choosing (and persisting) one on the
// first call of the day. Returns (nil, nil) only if there's genuinely no // first call of the day. Returns (nil, nil) only if there's genuinely no
// content to pick. // content to pick.
var (
// ErrVaniBadKind is a kind outside verse/prose/transcript.
ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
)
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) { func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
now := time.Now().UTC() now := time.Now().UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
@@ -75,6 +85,136 @@ func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
// choose tries the three content kinds in random order and returns the first // choose tries the three content kinds in random order and returns the first
// successful sample, so each day's pick varies across content types. // successful sample, so each day's pick varies across content types.
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
//
// The intended admin workflow: paste the link to the thing you want. Search
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
// matches on "london" and returns unrelated lectures.
//
// Accepts an absolute URL or a path, and understands the three shapes the
// vāṇī can point at:
//
// /transcriptions/{slug}
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
// /books/{book}/{slug} (prose chapter)
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
day = day.UTC().Truncate(24 * time.Hour)
path := strings.TrimSpace(raw)
if u, err := url.Parse(path); err == nil && u.Host != "" {
path = u.Path
}
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, ErrVaniBadURL
}
switch parts[0] {
case "transcriptions":
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
if err != nil {
return nil, err
}
return transcriptPick(t, day), nil
case "books":
book := parts[1]
nums := parts[2:]
// A trailing division segment (SB cantos, CC līlās) shifts the
// chapter/verse pair along by one.
var division *string
if len(nums) == 3 {
d := nums[0]
division, nums = &d, nums[1:]
}
if len(nums) == 2 {
chapter, err1 := strconv.Atoi(nums[0])
verse, err2 := strconv.Atoi(nums[1])
if err1 == nil && err2 == nil {
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
if err != nil {
return nil, err
}
return versePick(v, day), nil
}
}
// Not numeric, so it is a prose chapter's bare slug.
if len(nums) == 1 {
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
if err != nil {
return nil, err
}
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
}
}
return nil, ErrVaniBadURL
}
// SetByURL resolves a URL and pins the result.
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
pick, err := s.ResolveURL(ctx, raw, day)
if err != nil {
return nil, err
}
if err := s.repo.Upsert(ctx, pick); err != nil {
return nil, err
}
return pick, nil
}
// SetForDay pins a specific piece of content as a given day's Vāṇī.
//
// Takes only a kind and a content id — never a payload. The display blob is
// rebuilt here with the same builders the random picker uses, so a pinned pick
// is indistinguishable from an automatic one and a client cannot dictate what
// the home page says.
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
day = day.UTC().Truncate(24 * time.Hour)
var pick *model.VaniPick
switch kind {
case "verse":
v, err := s.repo.VerseByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = versePick(v, day)
case "prose":
p, err := s.repo.ProseByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
case "transcript":
t, err := s.repo.TranscriptByHex(ctx, hex)
if err != nil {
return nil, err
}
pick = transcriptPick(t, day)
default:
return nil, ErrVaniBadKind
}
if err := s.repo.Upsert(ctx, pick); err != nil {
return nil, err
}
return pick, nil
}
// ClearForDay drops a pinned pick, handing the day back to the random picker.
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
}
// Schedule lists decided days from today onward, for the admin view.
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
if limit <= 0 || limit > 60 {
limit = 30
}
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
}
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick { func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
kinds := []string{"verse", "prose", "transcript"} kinds := []string{"verse", "prose", "transcript"}
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] }) rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
+7
View File
@@ -0,0 +1,7 @@
DROP TABLE IF EXISTS message_reports;
DROP TABLE IF EXISTS message_stars;
DROP TABLE IF EXISTS message_attachments;
DROP TABLE IF EXISTS message_hides;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS conversation_participants;
DROP TABLE IF EXISTS conversations;
+203
View File
@@ -0,0 +1,203 @@
-- Chat — DMs, group threads, and Kautilya (AI) threads.
--
-- One pipeline serves all three. `conversations.kind` is the only thing that
-- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a
-- second implementation, and the message list, composer, pagination and
-- context menu are written once.
--
-- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and
-- timestamps collide; a gapless integer gives stable ordering, cheap unread
-- maths (last_seq - last_read_seq), and a sync cursor the client can resume
-- from after a reconnect (?after_seq=N).
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- dm → exactly two participants
-- group → many participants, has a title/avatar
-- ai → one participant; the other side is Kautilya (no user row)
kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')),
title VARCHAR(120), -- groups (and AI thread titles)
avatar_key TEXT, -- R2 key, groups only
-- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes
-- "one thread per pair" a database guarantee instead of a service-layer
-- race between two people opening the chat simultaneously.
dm_key TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- Monotonic message counter. Bumped inside the send transaction, which
-- serialises assignment per conversation and leaves no gaps.
last_seq BIGINT NOT NULL DEFAULT 0,
-- Denormalized purely for inbox ordering; the preview text and its sender
-- are read with a LATERAL join on (conversation_id, seq DESC) so there is
-- one source of truth for message content even after an edit or delete.
last_message_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (kind <> 'dm' OR dm_key IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq
ON conversations (dm_key) WHERE kind = 'dm';
CREATE TABLE IF NOT EXISTS conversation_participants (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(8) NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'member')),
-- Delivery/read state lives here, per participant — NOT per message. A
-- receipt row per (message × participant) would grow with the product of
-- both; two cursors give the same three tick states at constant size:
-- sent → the row exists
-- delivered → last_delivered_seq >= message.seq
-- read → last_read_seq >= message.seq
last_read_seq BIGINT NOT NULL DEFAULT 0,
last_delivered_seq BIGINT NOT NULL DEFAULT 0,
muted_until TIMESTAMPTZ,
-- "Friends since" on the chat profile reads joined_at.
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Non-null = left the group. Kept (rather than deleted) so their past
-- messages still resolve to a member row when rendering history.
left_at TIMESTAMPTZ,
PRIMARY KEY (conversation_id, user_id)
);
-- The inbox query: this user's threads, most recent first. Partial on left_at
-- so departed groups drop out without a filter on every read.
CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx
ON conversation_participants (user_id) WHERE left_at IS NULL;
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
-- NULL for messages no user wrote: 'system' (membership changes) and
-- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread
-- readable for everyone else.
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
kind VARCHAR(12) NOT NULL DEFAULT 'text'
CHECK (kind IN ('text', 'system', 'assistant')),
body TEXT NOT NULL DEFAULT '',
-- Inline formatting and detected links as offset/length spans
-- ([{type,offset,length}]), never HTML. Storing spans instead of markup
-- keeps rendering injection-safe and lets each client render natively.
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Client-generated id, so a retried send is deduped rather than doubled.
client_msg_id UUID,
reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL,
-- Set when this message was forwarded from another. SET NULL so deleting
-- the original leaves the forward intact.
forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ,
-- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and
-- other participants' clients can replace the bubble in place.
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (conversation_id, seq)
);
-- History paging, newest first, and the LATERAL that fetches each inbox row's
-- last message.
CREATE INDEX IF NOT EXISTS messages_conversation_idx
ON messages (conversation_id, seq DESC);
-- Idempotent send. Partial because client_msg_id is NULL for system and
-- assistant messages, which no client retries.
CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup
ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
-- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone):
-- this hides a message from one person's view while others keep seeing it.
CREATE TABLE IF NOT EXISTS message_hides (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE IF NOT EXISTS message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
-- Denormalized from the parent message so the chat profile's "shared
-- media" and "shared links" tabs are one index range scan, with no join
-- back through messages.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL DEFAULT 0,
kind VARCHAR(8) NOT NULL
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')),
-- Media (kind in image/video/audio/file). r2_key points at the PRIVATE
-- bucket: chat media is never public-by-URL, it is read through a
-- participant-checked presigned GET.
r2_key TEXT,
mime VARCHAR(128),
size_bytes BIGINT,
width INTEGER,
height INTEGER,
duration_ms INTEGER,
blurhash VARCHAR(64), -- instant placeholder while loading
file_name TEXT,
-- Link preview (kind = 'link'). Unfurled server-side AFTER the message is
-- persisted, so a slow remote page never delays the send. Absent when the
-- sender dismissed the preview in the composer.
url TEXT,
title TEXT,
description TEXT,
site_name VARCHAR(120),
image_url TEXT,
-- Shared post / comment / profile / collection (kind = 'entity').
-- entity_id is TEXT because these ids are not all UUIDs, and there is
-- deliberately no FK: the entity may be deleted or turn private later.
-- `snapshot` is a fallback for rendering, but permission is re-checked
-- per viewer at read time — a collection made private must stop resolving
-- for the recipient, so the snapshot alone must never be trusted.
entity_type VARCHAR(16) CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection')),
entity_id TEXT,
snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS message_attachments_message_idx
ON message_attachments (message_id, ord);
-- The chat profile's media / links tabs.
CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx
ON message_attachments (conversation_id, kind, created_at DESC);
-- Starred messages are per-user, not per-conversation: two people in the same
-- thread star different things.
CREATE TABLE IF NOT EXISTS message_stars (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Denormalized so "starred in this chat" is a single index range scan.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx
ON message_stars (user_id, conversation_id, created_at DESC);
-- Reported messages. `snapshot` is the whole point: it copies the reported
-- message (and limited surrounding context) at report time so moderators read
-- a report record and never need read access to live threads. message_id is
-- SET NULL on delete so a report survives the sender deleting the evidence.
CREATE TABLE IF NOT EXISTS message_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL DEFAULT '',
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
resolution_note TEXT
);
CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx
ON message_reports (created_at DESC) WHERE resolved_at IS NULL;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS conversation_participants_role_idx;
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_send_policy_check;
ALTER TABLE conversations DROP COLUMN IF EXISTS send_policy;
+26
View File
@@ -0,0 +1,26 @@
-- Who may post in a group.
--
-- 'everyone' keeps existing groups behaving exactly as they do today, which is
-- why it is the default: this migration must not silently mute anyone.
-- 'admins' makes the thread an announcement channel — owners and admins post,
-- everyone else reads.
--
-- DMs and Kautilya threads ignore this: a two-party conversation has no
-- administrators, and the AI thread has one participant.
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS send_policy VARCHAR(8) NOT NULL DEFAULT 'everyone';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'conversations_send_policy_check'
) THEN
ALTER TABLE conversations
ADD CONSTRAINT conversations_send_policy_check
CHECK (send_policy IN ('everyone', 'admins'));
END IF;
END $$;
-- "Who are this group's admins?" runs on every post in an admins-only thread.
CREATE INDEX IF NOT EXISTS conversation_participants_role_idx
ON conversation_participants (conversation_id, role) WHERE left_at IS NULL;
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS chat_settings;
+37
View File
@@ -0,0 +1,37 @@
-- Per-user chat preferences.
--
-- A row is created on first change, so absence means "all defaults". That is
-- why every column carries the product default rather than something neutral:
-- reading a missing row and reading an untouched row must give the same answer.
CREATE TABLE IF NOT EXISTS chat_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Read receipts are reciprocal: turning them off also stops you seeing
-- other people's. Enforced in the service, not here.
read_receipts BOOLEAN NOT NULL DEFAULT TRUE,
-- Who may START a conversation with this user:
-- anyone — no restriction (default)
-- friends — people they follow, or who follow them, accepted
-- account_age — accounts at least min_account_age_months old
-- same_gender — accounts whose gender matches theirs; requires the user
-- to have set their own gender, since there is nothing to
-- match against otherwise
who_can_message VARCHAR(16) NOT NULL DEFAULT 'anyone',
-- Only meaningful when who_can_message = 'account_age'.
min_account_age_months INTEGER NOT NULL DEFAULT 3,
-- Consent for using this user's chats as training data for the Kautilya
-- assistant. Recorded now so the choice exists before the feature does —
-- collecting consent retroactively is worse than collecting it early.
train_kautilya BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chat_settings_who_can_message_check
CHECK (who_can_message IN ('anyone', 'friends', 'account_age', 'same_gender')),
CONSTRAINT chat_settings_account_age_check
CHECK (min_account_age_months BETWEEN 1 AND 120)
);
@@ -0,0 +1,8 @@
-- Rows of the new kind must go before the old constraint can be restored.
DELETE FROM message_attachments WHERE kind = 'quote';
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity'));
+18
View File
@@ -0,0 +1,18 @@
-- A shared passage from the library, sent into a chat.
--
-- Modelled as an attachment rather than message text so it can be rendered as
-- a quote and navigated back to. It reuses the columns the link card already
-- has, since the shape is the same thing said differently:
--
-- url → the ?hl= deep link back to the exact phrase
-- title → the source reference ("Bhagavad-gītā 2.13")
-- description → the quoted passage itself
--
-- It is a distinct kind rather than a 'link' because it renders as a quote,
-- not a preview card, and because its URL is always our own — the service
-- refuses any other origin.
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote'));
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS broadcast_views;
DROP TABLE IF EXISTS broadcasts;
+44
View File
@@ -0,0 +1,44 @@
-- Admin broadcasts — product updates and announcements, shown once per user.
--
-- On "no history": these deliberately create no notification rows and never
-- appear in the tray. But "exactly once per user" cannot be done without
-- storing something — the app has to know whether YOU have seen it. So two
-- tables: the announcement, and who has dismissed it. Nothing per-user is
-- written until that person actually sees it.
CREATE TABLE IF NOT EXISTS broadcasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL DEFAULT '',
-- Formatting as offset/length spans, exactly like chat messages: the same
-- parser produces them and the same component renders them, so no HTML is
-- stored and nothing needs sanitising at render time.
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
-- R2 key for the banner. Public bucket: an announcement is not private,
-- and a signed URL would expire while the modal sat open.
banner_key TEXT,
cta_label VARCHAR(60),
-- App-relative path or absolute http(s) URL; validated in the service.
cta_url TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- NULL = draft. Nothing is delivered until this is set.
published_at TIMESTAMPTZ,
-- After this, the announcement stops being offered. This is what keeps a
-- months-old update from greeting someone who signs up tomorrow.
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "What is live right now?" — the app's pending lookup and the admin list.
CREATE INDEX IF NOT EXISTS broadcasts_live_idx
ON broadcasts (published_at DESC) WHERE published_at IS NOT NULL;
-- One row per person who has seen an announcement. Written on dismissal, which
-- is what makes "at most once" true across devices — localStorage would show
-- it again on every new browser.
CREATE TABLE IF NOT EXISTS broadcast_views (
broadcast_id UUID NOT NULL REFERENCES broadcasts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (broadcast_id, user_id)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS message_reactions;
+27
View File
@@ -0,0 +1,27 @@
-- Emoji reactions on chat messages.
--
-- One reaction per person per message: reacting again REPLACES rather than
-- accumulates, which is what the primary key enforces. That matches how the
-- UI reads — a row of distinct emoji with counts — and stops someone stacking
-- twenty reactions on one bubble.
--
-- The emoji is stored as text rather than a code point or shortname: what the
-- sender picked is what everyone should see, and a mapping table would need
-- updating every time the emoji set grows.
CREATE TABLE IF NOT EXISTS message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Denormalized so deleting a conversation takes its reactions with it
-- without relying on the messages cascade alone.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
emoji VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id),
-- A stray empty string would render as an invisible reaction chip.
CONSTRAINT message_reactions_emoji_not_blank CHECK (btrim(emoji) <> '')
);
-- Hydrating a page of messages: one index range scan per batch.
CREATE INDEX IF NOT EXISTS message_reactions_message_idx
ON message_reactions (message_id);
+14
View File
@@ -0,0 +1,14 @@
DELETE FROM message_attachments WHERE kind = 'sticker';
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote'));
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_entity_type_check
CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection'));
DROP TABLE IF EXISTS saved_stickers;
+43
View File
@@ -0,0 +1,43 @@
-- Stickers in chat.
--
-- A sticker IS a sādhana reward — same catalog, same artwork — so there is no
-- separate sticker table. What's new is a second way to come by one: someone
-- sends you theirs and you keep it.
--
-- saved_stickers records only that second route. A reward you earned is
-- already yours through sadhna_reward_grants, and duplicating it here would
-- give two answers to "do I own this?".
CREATE TABLE IF NOT EXISTS saved_stickers (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reward_id UUID NOT NULL REFERENCES sadhna_rewards(id) ON DELETE CASCADE,
-- Who sent it. Kept for provenance, and SET NULL rather than CASCADE so
-- deleting an account doesn't take away stickers other people saved.
saved_from UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, reward_id)
);
-- "What's in my sticker drawer?" — the whole reason this table exists.
CREATE INDEX IF NOT EXISTS saved_stickers_user_idx
ON saved_stickers (user_id, created_at DESC);
-- A sticker message: kind 'sticker', with the artwork denormalized onto the
-- attachment's existing image_url/title columns.
--
-- Denormalized rather than resolved per read, unlike a shared post: the
-- catalog is static art with no viewer-dependent visibility, so there is
-- nothing to re-check and a join on every message would buy nothing.
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote', 'sticker'));
-- entity_id carries the reward key on a sticker attachment, so a recipient can
-- save it without the client having to trust a URL it was handed.
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
ALTER TABLE message_attachments
ADD CONSTRAINT message_attachments_entity_type_check
CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection', 'sticker'));
+12
View File
@@ -0,0 +1,12 @@
DELETE FROM sadhna_reward_grants WHERE source = 'gift';
DROP INDEX IF EXISTS sadhna_reward_grants_unopened_idx;
DROP INDEX IF EXISTS sadhna_reward_grants_gift_uniq;
DROP INDEX IF EXISTS sadhna_reward_grants_streak_uniq;
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone SET NOT NULL;
ALTER TABLE sadhna_reward_grants
ADD CONSTRAINT sadhna_reward_grants_user_id_milestone_key UNIQUE (user_id, milestone);
ALTER TABLE sadhna_reward_grants DROP CONSTRAINT IF EXISTS sadhna_reward_grants_source_check;
ALTER TABLE sadhna_reward_grants DROP COLUMN IF EXISTS source;
+41
View File
@@ -0,0 +1,41 @@
-- Occasional gift rewards, granted from the admin panel.
--
-- A gift IS a reward grant — same catalog, same gallery, same vault — so it
-- reuses sadhna_reward_grants rather than introducing a parallel table that
-- every reader would then have to union in. What differs is only where it came
-- from, which `source` records.
--
-- `celebrated_at` already means "the devotee has seen this", which is exactly
-- the claim flag a gift needs: unclaimed gifts render blurred, and dismissing
-- the celebration is what clears them.
ALTER TABLE sadhna_reward_grants
ADD COLUMN IF NOT EXISTS source VARCHAR(8) NOT NULL DEFAULT 'streak';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sadhna_reward_grants_source_check') THEN
ALTER TABLE sadhna_reward_grants
ADD CONSTRAINT sadhna_reward_grants_source_check
CHECK (source IN ('streak', 'gift'));
END IF;
END $$;
-- A gift is not tied to a streak day, so it has no milestone.
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone DROP NOT NULL;
-- UNIQUE (user_id, milestone) was the streak rule: one reward per milestone.
-- It can't police gifts, which have no milestone — so it becomes a partial
-- index over streak rows, and gifts get their own rule: one of each reward
-- per person, so re-gifting is idempotent rather than a duplicate tile.
ALTER TABLE sadhna_reward_grants
DROP CONSTRAINT IF EXISTS sadhna_reward_grants_user_id_milestone_key;
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_streak_uniq
ON sadhna_reward_grants (user_id, milestone) WHERE source = 'streak';
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_gift_uniq
ON sadhna_reward_grants (user_id, reward_id) WHERE source = 'gift';
-- "Which gifts is this devotee still to open?" — the blurred tiles.
CREATE INDEX IF NOT EXISTS sadhna_reward_grants_unopened_idx
ON sadhna_reward_grants (user_id) WHERE celebrated_at IS NULL;
+7
View File
@@ -0,0 +1,7 @@
-- Restore the old flag for anyone who still has a role, so a rollback keeps
-- them in the panel.
UPDATE users SET role = 'admin' WHERE admin_role_id IS NOT NULL AND admin_blocked_at IS NULL;
DROP INDEX IF EXISTS users_admin_role_idx;
ALTER TABLE users DROP COLUMN IF EXISTS admin_blocked_at;
ALTER TABLE users DROP COLUMN IF EXISTS admin_role_id;
DROP TABLE IF EXISTS admin_roles;
+60
View File
@@ -0,0 +1,60 @@
-- Role-based access for the admin panel.
--
-- Until now admin access was one flag: users.role = 'admin'. This replaces it
-- with named roles carrying explicit permissions, so a moderator can resolve
-- reports without also being able to ban users or gift stickers to everyone.
--
-- Three system roles are seeded and cannot be edited or deleted; superadmins
-- can define further custom roles from any subset of the permission catalog
-- (which lives in code — see internal/rbac). The one thing a custom role can
-- never hold is admins:manage: granting, changing, revoking and blocking staff
-- stays with superadmins.
CREATE TABLE IF NOT EXISTS admin_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(80) NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- System roles are seeded below and are read-only.
is_system BOOLEAN NOT NULL DEFAULT FALSE,
-- "resource:action" strings, or '*' for everything (superadmin only).
permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO admin_roles (key, name, description, is_system, permissions) VALUES
('superadmin', 'Superadmin',
'Everything, including managing other staff.', TRUE, ARRAY['*']),
('admin', 'Admin',
'Runs the platform day to day. Cannot manage staff.', TRUE, ARRAY[
'stats:read','users:read','users:notify','users:ban','users:limits',
'disciples:manage','posts:moderate','comments:moderate','collections:read',
'feedback:read','feedback:reply','feedback:resolve',
'reports:read','reports:resolve','vani:read','vani:write',
'rewards:read','rewards:write','rewards:send','rewards:delete',
'broadcasts:read','broadcasts:write','broadcasts:publish',
'short_urls:read','quizzes:read','quizzes:write','quizzes:delete','quizzes:results']),
('moderator', 'Moderator',
'Keeps the community healthy: reports, moderation and feedback.', TRUE, ARRAY[
'stats:read','users:read','posts:moderate','comments:moderate',
'collections:read','feedback:read','feedback:reply','feedback:resolve',
'reports:read','reports:resolve'])
ON CONFLICT (key) DO NOTHING;
-- Staff membership. NULL = not staff.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS admin_role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT;
-- A superadmin can suspend another staff member's panel access without taking
-- their role away, so restoring them is one click rather than a re-grant.
ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_blocked_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS users_admin_role_idx
ON users (admin_role_id) WHERE admin_role_id IS NOT NULL;
-- Everyone who was an admin under the old single flag becomes a superadmin:
-- they had unrestricted access before, so anything less would lock existing
-- staff out of the panel the moment this deploys.
UPDATE users
SET admin_role_id = (SELECT id FROM admin_roles WHERE key = 'superadmin')
WHERE role = 'admin' AND admin_role_id IS NULL;
+5
View File
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS quiz_answers;
DROP TABLE IF EXISTS quiz_attempts;
DROP TABLE IF EXISTS quiz_options;
DROP TABLE IF EXISTS quiz_questions;
DROP TABLE IF EXISTS quizzes;
+88
View File
@@ -0,0 +1,88 @@
-- Quizzes on the library's content.
--
-- A quiz is attached to a piece of content by its app path — a book chapter
-- (/books/bg/2), or a lecture or conversation (/transcriptions/{slug}) — or to
-- nothing at all, for a stand-alone quiz. The path is what the content page
-- already knows about itself, so it can ask "is there a quiz for me?" without
-- the content's Mongo id ever reaching Postgres.
CREATE TABLE IF NOT EXISTS quizzes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
-- Light formatting markers, rendered like the rest of the admin text.
description TEXT NOT NULL DEFAULT '',
-- chapter | lecture | conversation | standalone
target_kind VARCHAR(16) NOT NULL DEFAULT 'standalone',
-- App path of the content, NULL for a stand-alone quiz.
target_path TEXT,
-- Display name of the content, captured when the quiz is made.
target_title TEXT NOT NULL DEFAULT '',
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Soft delete: participants' results stay intact for the record.
deleted_at TIMESTAMPTZ,
CONSTRAINT quizzes_target_kind_check
CHECK (target_kind IN ('chapter', 'lecture', 'conversation', 'standalone')),
CONSTRAINT quizzes_target_consistent
CHECK ((target_kind = 'standalone') = (target_path IS NULL))
);
-- "Is there a quiz for this page?" — asked by every content page load.
CREATE INDEX IF NOT EXISTS quizzes_target_idx
ON quizzes (target_path, published_at DESC)
WHERE deleted_at IS NULL AND published_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS quiz_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL,
prompt TEXT NOT NULL,
-- Optional note shown with the answer on the results screen.
explanation TEXT NOT NULL DEFAULT '',
UNIQUE (quiz_id, ord)
);
CREATE TABLE IF NOT EXISTS quiz_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL,
body TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (question_id, ord),
-- Five options at most; the service also enforces two at least and
-- exactly one correct.
CONSTRAINT quiz_options_ord_check CHECK (ord BETWEEN 0 AND 4)
);
-- One attempt per person per quiz: it is what "Continue" resumes and what
-- "View results" shows.
CREATE TABLE IF NOT EXISTS quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
score INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
UNIQUE (quiz_id, user_id)
);
CREATE INDEX IF NOT EXISTS quiz_attempts_user_idx ON quiz_attempts (user_id);
-- One row per question an attempt has reached.
--
-- Timing is measured by the SERVER: shown_at is stamped when the question is
-- handed to the client, answered_at when the answer arrives. A client-reported
-- duration could be anything; these two timestamps can't be edited from the
-- browser.
CREATE TABLE IF NOT EXISTS quiz_answers (
attempt_id UUID NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL,
shown_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ,
response_ms INTEGER,
is_correct BOOLEAN,
PRIMARY KEY (attempt_id, question_id)
);
@@ -0,0 +1,5 @@
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL;
-- Lossy for multi answers: keeps the first chosen option only.
UPDATE quiz_answers SET option_id = option_ids[1] WHERE option_ids IS NOT NULL;
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_ids;
ALTER TABLE quiz_questions DROP COLUMN IF EXISTS multi;
+14
View File
@@ -0,0 +1,14 @@
-- Multiple-choice questions with more than one correct answer.
--
-- A question marked multi asks the reader to "select all that apply"; it is
-- scored correct only when the chosen set equals the correct set exactly. A
-- single-answer question keeps exactly one correct option.
ALTER TABLE quiz_questions ADD COLUMN IF NOT EXISTS multi BOOLEAN NOT NULL DEFAULT FALSE;
-- An answer is now a set of options. Arrays can't carry a foreign key; that
-- is safe here because a quiz's options are frozen once anyone has answered,
-- and options only ever go away with their whole question.
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_ids UUID[];
UPDATE quiz_answers SET option_ids = ARRAY[option_id]
WHERE option_id IS NOT NULL AND option_ids IS NULL;
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_id;
+121
View File
@@ -0,0 +1,121 @@
// Package richtext renders the light formatting the admin panel's message
// fields allow, for the two places that text leaves the app:
//
// - ToHTML, for email bodies.
// - Plain, for push notifications and toasts, which can't render markup.
//
// The format is a handful of markers typed into a plain textarea:
//
// *bold* _italic_ __underline__ ~strike~ [label](https://…)
//
// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~
// match what chat already uses, so the two read the same.
//
// Safety comes from the order of operations: the input is HTML-escaped FIRST,
// and only then are the markers turned into tags. Nothing an author types can
// therefore produce markup of its own — the only tags in the output are the
// ones this package writes. Links are limited to http and https, so a
// `javascript:` URL is never made clickable.
package richtext
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
)
var (
// A labelled link, or a bare URL. One alternation so a URL is never
// linked twice — once as the label's target and once on its own.
linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`)
underlineRe = regexp.MustCompile(`__([^_\n]+?)__`)
boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`)
strikeRe = regexp.MustCompile(`~([^~\n]+?)~`)
// Italic needs a non-word character (or an edge) on each side, so the
// underscores inside snake_case or a handle aren't read as markers. RE2
// has no lookbehind, so the neighbours are captured and put back.
italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`)
// Punctuation that ends a sentence rather than belonging to the URL.
trailingPunct = ".,;:!?)"
)
// placeholder marks where a finished link sits while markers are applied, so
// the underscores and tildes common in URLs aren't read as formatting.
func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) }
// splitTrailing separates sentence punctuation from the end of a bare URL.
func splitTrailing(u string) (url, tail string) {
end := len(u)
for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) {
end--
}
return u[:end], u[end:]
}
// ToHTML renders the markers as HTML for an email body.
func ToHTML(s string) template.HTML {
escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n"))
var links []string
withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string {
parts := linkRe.FindStringSubmatch(m)
var label, href, tail string
if parts[1] != "" {
label, href = parts[1], parts[2]
} else {
href, tail = splitTrailing(parts[3])
label = href
}
// href is already escaped (it came from the escaped input), so it is
// safe inside the attribute as-is.
links = append(links, fmt.Sprintf(
`<a href="%s" style="color:#9a6b2f;font-weight:600">%s</a>`, href, label))
return placeholder(len(links)-1) + tail
})
out := underlineRe.ReplaceAllString(withHolders, `<u>$1</u>`)
out = boldRe.ReplaceAllString(out, `<strong>$1</strong>`)
out = strikeRe.ReplaceAllString(out, `<s>$1</s>`)
out = italicRe.ReplaceAllString(out, `$1<em>$2</em>$3`)
for i, l := range links {
out = strings.Replace(out, placeholder(i), l, 1)
}
out = strings.ReplaceAll(out, "\n", "<br>")
// Every tag here was written by this function, and all author text was
// escaped before any of them were added.
return template.HTML(out) //nolint:gosec // see above
}
// Plain strips the markers for surfaces that can't render them — a push
// notification or a toast. A labelled link keeps only its label; a bare URL
// stays as written, since it is still useful to see.
func Plain(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
var links []string
withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string {
parts := linkRe.FindStringSubmatch(m)
if parts[1] != "" {
links = append(links, parts[1])
} else {
links = append(links, parts[3])
}
return placeholder(len(links) - 1)
})
out := underlineRe.ReplaceAllString(withHolders, `$1`)
out = boldRe.ReplaceAllString(out, `$1`)
out = strikeRe.ReplaceAllString(out, `$1`)
out = italicRe.ReplaceAllString(out, `$1$2$3`)
for i, l := range links {
out = strings.Replace(out, placeholder(i), l, 1)
}
return out
}
+85
View File
@@ -0,0 +1,85 @@
package richtext
import (
"strings"
"testing"
)
func TestToHTML_Markers(t *testing.T) {
cases := []struct{ in, want string }{
{"*bold*", "<strong>bold</strong>"},
{"_italic_", "<em>italic</em>"},
{"__under__", "<u>under</u>"},
{"~gone~", "<s>gone</s>"},
{"a *b* and _c_", "a <strong>b</strong> and <em>c</em>"},
{"line one\nline two", "line one<br>line two"},
// Underscores inside words are not italics.
{"snake_case_name", "snake_case_name"},
{"@some_handle here", "@some_handle here"},
}
for _, c := range cases {
if got := string(ToHTML(c.in)); got != c.want {
t.Errorf("ToHTML(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestToHTML_Links(t *testing.T) {
got := string(ToHTML("see [the gītā](https://prabhupadasays.org/books/bg)"))
if !strings.Contains(got, `href="https://prabhupadasays.org/books/bg"`) ||
!strings.Contains(got, ">the gītā</a>") {
t.Errorf("labelled link: %q", got)
}
// A bare URL becomes a link, but sentence punctuation stays outside it.
got = string(ToHTML("Visit https://prabhupadasays.org."))
if !strings.Contains(got, `href="https://prabhupadasays.org"`) ||
!strings.HasSuffix(got, "</a>.") {
t.Errorf("bare link with trailing period: %q", got)
}
// Underscores and tildes inside a URL are not formatting.
got = string(ToHTML("https://example.com/a_b_c~d"))
if strings.Contains(got, "<em>") || strings.Contains(got, "<s>") {
t.Errorf("markers applied inside a URL: %q", got)
}
}
// The security property: nothing typed can become markup of its own.
func TestToHTML_EscapesAuthorInput(t *testing.T) {
cases := []string{
`<script>alert(1)</script>`,
`<img src=x onerror=alert(1)>`,
`*<b>bold</b>*`,
`[click](javascript:alert(1))`,
`[x](https://ok.example/" onmouseover="alert(1))`,
}
for _, in := range cases {
got := string(ToHTML(in))
if strings.Contains(got, "<script") || strings.Contains(got, "<img") ||
strings.Contains(got, "<b>") || strings.Contains(got, "javascript:alert") && strings.Contains(got, "href=\"javascript") {
t.Errorf("ToHTML(%q) produced author markup: %q", in, got)
}
if strings.Contains(got, `" onmouseover=`) {
t.Errorf("ToHTML(%q) let a quote break out of an attribute: %q", in, got)
}
}
// A javascript: URL is never made a link at all.
if got := string(ToHTML(`[click](javascript:alert(1))`)); strings.Contains(got, "<a ") {
t.Errorf("javascript: URL was linked: %q", got)
}
}
func TestPlain(t *testing.T) {
cases := []struct{ in, want string }{
{"*New:* _chat_ is __here__ ~soon~", "New: chat is here soon"},
{"read [the gītā](https://x.example)", "read the gītā"},
{"see https://prabhupadasays.org", "see https://prabhupadasays.org"},
{"snake_case stays", "snake_case stays"},
}
for _, c := range cases {
if got := Plain(c.in); got != c.want {
t.Errorf("Plain(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+313
View File
@@ -0,0 +1,313 @@
// Package unfurl fetches link preview metadata for URLs users paste into chat.
//
// Fetching a user-supplied URL from the server is a server-side request forgery
// primitive: without care, "https://x" can be pointed at 169.254.169.254 (cloud
// metadata), at a database on 10.x, or at localhost. Everything below exists to
// make that impossible:
//
// - only http/https,
// - every connection is vetted at the RESOLVED IP, so a hostname that
// resolves to a private address is refused (and re-checked on each redirect
// hop, which is what defeats DNS rebinding),
// - redirects capped, response size capped, timeouts on everything,
// - only text/html is parsed.
//
// The metadata is deliberately fetched by the server rather than accepted from
// the client. A client that could supply the title and image could make any
// link claim to be anything.
package unfurl
import (
"context"
"errors"
"fmt"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"syscall"
"time"
)
// ErrBlocked is returned for a URL that resolves somewhere we refuse to fetch.
var ErrBlocked = errors.New("refusing to fetch that address")
// ErrNotHTML is returned when the response isn't a page we can read metadata
// from — a PDF or an image has nothing to unfurl.
var ErrNotHTML = errors.New("not an HTML page")
const (
maxRedirects = 3
maxBodyBytes = 512 << 10 // 512 KiB is far more than any <head>
fetchTimeout = 6 * time.Second
dialTimeout = 3 * time.Second
maxFieldRunes = 300
)
// Preview is the metadata rendered as a link card.
type Preview struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SiteName string `json:"site_name,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
// Fetcher retrieves previews. Safe for concurrent use.
type Fetcher struct {
client *http.Client
// allowLoopback exists only so the package's own tests can point at an
// httptest server. It is unexported and never set outside this package.
allowLoopback bool
}
// New returns a Fetcher with every guard enabled.
func New() *Fetcher {
f := &Fetcher{}
f.client = &http.Client{
Timeout: fetchTimeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: dialTimeout,
// Control runs after DNS resolution with the address actually
// being dialled, so it sees through a hostname that points at
// a private range — including one that changes between the
// lookup and the connection.
Control: func(network, address string, _ syscall.RawConn) error {
return f.checkAddr(network, address)
},
}).DialContext,
TLSHandshakeTimeout: dialTimeout,
ResponseHeaderTimeout: fetchTimeout,
DisableKeepAlives: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("too many redirects")
}
if !allowedScheme(req.URL) {
return ErrBlocked
}
return nil
},
}
return f
}
func (f *Fetcher) checkAddr(network, address string) error {
if network != "tcp4" && network != "tcp6" && network != "tcp" {
return ErrBlocked
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return ErrBlocked
}
ip := net.ParseIP(host)
if ip == nil {
return ErrBlocked
}
if f.allowLoopback && ip.IsLoopback() {
return nil
}
if blockedIP(ip) {
return ErrBlocked
}
return nil
}
// blockedIP reports whether an address is one we refuse to talk to.
func blockedIP(ip net.IP) bool {
// Normalise IPv4-mapped IPv6 (::ffff:127.0.0.1) so the v4 rules apply.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
return true
}
// Ranges Go's helpers don't cover but which still reach infrastructure.
for _, cidr := range extraBlocked {
if cidr.Contains(ip) {
return true
}
}
return false
}
var extraBlocked = func() []*net.IPNet {
nets := []string{
"100.64.0.0/10", // CGNAT
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"198.18.0.0/15", // benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"240.0.0.0/4", // reserved
"::/128", // unspecified
"64:ff9b::/96", // NAT64, can map to private v4
}
out := make([]*net.IPNet, 0, len(nets))
for _, n := range nets {
if _, cidr, err := net.ParseCIDR(n); err == nil {
out = append(out, cidr)
}
}
return out
}()
func allowedScheme(u *url.URL) bool {
return u.Scheme == "http" || u.Scheme == "https"
}
// Fetch retrieves a preview for rawURL.
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Preview, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
if !allowedScheme(u) || u.Host == "" {
return nil, ErrBlocked
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
// Identify honestly; some sites serve richer metadata to a known crawler
// and it gives operators someone to contact.
req.Header.Set("User-Agent", "PrabhupadaSaysBot/1.0 (+https://prabhupadasays.org)")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := f.client.Do(req)
if err != nil {
// A blocked dial surfaces wrapped in a url.Error; unwrap so callers can
// tell "refused on purpose" from "the site is down".
if errors.Is(err, ErrBlocked) {
return nil, ErrBlocked
}
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unfurl status %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "" &&
!strings.HasPrefix(strings.ToLower(ct), "text/html") &&
!strings.HasPrefix(strings.ToLower(ct), "application/xhtml") {
return nil, ErrNotHTML
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
// resp.Request.URL is the FINAL url after redirects — relative images must
// resolve against where we actually landed.
final := resp.Request.URL
p := extract(string(body))
p.URL = final.String()
if p.ImageURL != "" {
if abs, err := final.Parse(p.ImageURL); err == nil && allowedScheme(abs) {
p.ImageURL = abs.String()
} else {
p.ImageURL = ""
}
}
if p.Title == "" && p.Description == "" {
return nil, ErrNotHTML
}
return p, nil
}
var (
metaRe = regexp.MustCompile(`(?is)<meta\s+([^>]*?)/?>`)
attrRe = regexp.MustCompile(`(?is)([a-z0-9:_.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
headRe = regexp.MustCompile(`(?is)</head\s*>`)
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
)
// extract pulls Open Graph metadata out of a document's head.
//
// Written against the meta tags themselves rather than a full HTML parse: the
// fields wanted here are a handful of well-formed <meta> elements, and this
// keeps the BFF free of another dependency.
func extract(doc string) *Preview {
// Metadata lives in <head>; stopping there avoids scanning a whole page.
if loc := headRe.FindStringIndex(doc); loc != nil {
doc = doc[:loc[0]]
}
p := &Preview{}
for _, m := range metaRe.FindAllStringSubmatch(doc, -1) {
attrs := parseAttrs(m[1])
key := attrs["property"]
if key == "" {
key = attrs["name"]
}
content := attrs["content"]
if content == "" {
continue
}
switch strings.ToLower(key) {
case "og:title", "twitter:title":
setIfEmpty(&p.Title, content)
case "og:description", "twitter:description", "description":
setIfEmpty(&p.Description, content)
case "og:site_name":
setIfEmpty(&p.SiteName, content)
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
setIfEmpty(&p.ImageURL, content)
}
}
if p.Title == "" {
if m := titleRe.FindStringSubmatch(doc); m != nil {
p.Title = clean(tagRe.ReplaceAllString(m[1], ""))
}
}
p.Title = truncate(p.Title, maxFieldRunes)
p.Description = truncate(p.Description, maxFieldRunes)
p.SiteName = truncate(p.SiteName, maxFieldRunes)
return p
}
func parseAttrs(s string) map[string]string {
out := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(s, -1) {
value := m[3]
if value == "" {
value = m[4]
}
if value == "" {
value = m[5]
}
out[strings.ToLower(m[1])] = clean(value)
}
return out
}
func setIfEmpty(dst *string, v string) {
if *dst == "" {
*dst = v
}
}
func clean(s string) string {
return strings.TrimSpace(html.UnescapeString(strings.Join(strings.Fields(s), " ")))
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
+233
View File
@@ -0,0 +1,233 @@
package unfurl
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The blocklist is the security boundary of this package: everything else is
// plumbing around it. A URL that resolves into any of these ranges must never
// be fetched, or the BFF becomes a proxy into the private network.
func TestBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1", // loopback
"::1", // loopback v6
"::ffff:127.0.0.1", // loopback via IPv4-mapped v6
"10.0.0.5", // RFC1918
"172.16.4.2", // RFC1918
"192.168.1.1", // RFC1918
"::ffff:10.0.0.5", // RFC1918 via IPv4-mapped v6
"169.254.169.254", // cloud instance metadata
"fe80::1", // link-local v6
"fd00::1", // unique local v6
"0.0.0.0", // unspecified
"::", // unspecified v6
"224.0.0.1", // multicast
"100.64.0.1", // CGNAT
"198.18.0.1", // benchmarking
"240.0.0.1", // reserved
}
for _, s := range blocked {
if ip := net.ParseIP(s); ip == nil || !blockedIP(ip) {
t.Errorf("blockedIP(%s) = false, want true", s)
}
}
allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"}
for _, s := range allowed {
if ip := net.ParseIP(s); ip == nil || blockedIP(ip) {
t.Errorf("blockedIP(%s) = true, want false", s)
}
}
}
// A hostname that resolves to a private address must be refused at dial time —
// this is what stops "http://internal.example.com" and DNS rebinding.
func TestFetch_RefusesPrivateAddresses(t *testing.T) {
f := New() // loopback NOT allowed
for _, raw := range []string{
"http://127.0.0.1:80/",
"http://localhost/",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/",
"http://[::1]/",
} {
_, err := f.Fetch(context.Background(), raw)
if err == nil {
t.Errorf("Fetch(%s) succeeded — it must be refused", raw)
}
}
}
func TestFetch_RejectsNonHTTPSchemes(t *testing.T) {
f := New()
for _, raw := range []string{
"file:///etc/passwd",
"gopher://example.org/",
"ftp://example.org/",
"redis://10.0.0.1:6379",
"//example.org",
} {
if _, err := f.Fetch(context.Background(), raw); !errors.Is(err, ErrBlocked) {
t.Errorf("Fetch(%s) err = %v, want ErrBlocked", raw, err)
}
}
}
// testFetcher permits loopback so the fetch path can be exercised against an
// httptest server. The flag is unexported and set only here.
func testFetcher() *Fetcher {
f := New()
f.allowLoopback = true
return f
}
func TestFetch_ReadsOpenGraph(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<html><head>
<title>Fallback title</title>
<meta property="og:title" content="Bhagavad-g&#012ABC;t&#257; As It Is">
<meta property="og:description" content="Chapter 2, verse 13">
<meta property="og:site_name" content="Prabhupāda Says">
<meta property="og:image" content="/img/cover.jpg">
</head><body>ignored</body></html>`)
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Description != "Chapter 2, verse 13" {
t.Errorf("description = %q", p.Description)
}
if p.SiteName != "Prabhupāda Says" {
t.Errorf("site_name = %q", p.SiteName)
}
// Relative og:image must be resolved against the page it came from.
if !strings.HasPrefix(p.ImageURL, srv.URL) || !strings.HasSuffix(p.ImageURL, "/img/cover.jpg") {
t.Errorf("image = %q, want an absolute URL under %s", p.ImageURL, srv.URL)
}
}
func TestFetch_FallsBackToTitleTag(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head><title> Plain &amp; simple </title>
<meta name="description" content="From the name attribute"></head></html>`)
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Title != "Plain & simple" {
t.Errorf("title = %q, want entities decoded and whitespace collapsed", p.Title)
}
if p.Description != "From the name attribute" {
t.Errorf("description = %q", p.Description)
}
}
// A redirect that lands on a private address must be refused at the hop, not
// followed because the first host looked fine.
func TestFetch_RefusesRedirectToPrivateAddress(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); err == nil {
t.Fatal("followed a redirect into link-local space")
}
}
func TestFetch_RejectsNonHTML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
fmt.Fprint(w, "%PDF-1.7")
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
t.Errorf("err = %v, want ErrNotHTML", err)
}
}
// A page with no metadata is not a preview — better no card than an empty one.
func TestFetch_EmptyMetadataIsNotAPreview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head><body>nothing here</body></html>`)
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
t.Errorf("err = %v, want ErrNotHTML", err)
}
}
// A body far larger than the cap must not be read whole.
func TestFetch_CapsBodySize(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head><meta property="og:title" content="Huge"></head><body>`)
chunk := strings.Repeat("x", 64<<10)
for i := 0; i < 64; i++ { // 4 MiB, well past maxBodyBytes
fmt.Fprint(w, chunk)
}
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Title != "Huge" {
t.Errorf("title = %q", p.Title)
}
}
func TestExtract_LongFieldsAreTruncated(t *testing.T) {
long := strings.Repeat("ā", 500)
p := extract(`<meta property="og:title" content="` + long + `">`)
if len([]rune(p.Title)) > maxFieldRunes+1 {
t.Errorf("title kept %d runes, want it truncated", len([]rune(p.Title)))
}
if !strings.HasSuffix(p.Title, "…") {
t.Error("truncated title should be marked with an ellipsis")
}
}
func TestExtract_HandlesAttributeOrderAndQuotes(t *testing.T) {
p := extract(`
<meta content='Single quoted' property='og:title'>
<meta content=Unquoted name=og:site_name>
<meta property="og:description" content="Ordered normally">`)
if p.Title != "Single quoted" {
t.Errorf("title = %q", p.Title)
}
if p.SiteName != "Unquoted" {
t.Errorf("site_name = %q", p.SiteName)
}
if p.Description != "Ordered normally" {
t.Errorf("description = %q", p.Description)
}
}
// Metadata is read from <head>; a <meta> forged in the body must not win.
func TestExtract_IgnoresBodyContent(t *testing.T) {
p := extract(`<html><head><meta property="og:title" content="Real"></head>
<body><meta property="og:title" content="Forged"></body></html>`)
if p.Title != "Real" {
t.Errorf("title = %q, want the head's value", p.Title)
}
}