Compare commits
20
Commits
feat/secutils
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d76505772a | ||
|
|
fa0255e9cd | ||
|
|
eb02d08f14 | ||
|
|
b1f78b1655 | ||
|
|
4b38047c26 | ||
|
|
7d7ba2a002 | ||
|
|
6633d0cca3 | ||
|
|
0fee56d272 | ||
|
|
44fd1d69ab | ||
|
|
ac92bc2cf3 | ||
|
|
a2d22ed829 | ||
|
|
4427849647 | ||
|
|
fecb9bc045 | ||
|
|
cee2d3c874 | ||
|
|
9c458ba19f | ||
|
|
f65f552c87 | ||
|
|
44120efdec | ||
|
|
7a730cd42e | ||
|
|
18bac9a22d | ||
|
|
d50031cdb3 |
@@ -58,6 +58,8 @@ COOKIE_SECURE=false
|
||||
|
||||
# Frontend (CORS + email link base)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
|
||||
ADMIN_URL=
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
@@ -15,9 +17,15 @@ import (
|
||||
|
||||
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||
// the router, so every method here can assume an admin caller.
|
||||
type AdminHandler struct{ svc *service.AdminService }
|
||||
type AdminHandler struct {
|
||||
svc *service.AdminService
|
||||
// staff guards actions that would otherwise reach other staff members.
|
||||
staff *repository.StaffRepo
|
||||
}
|
||||
|
||||
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
|
||||
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
|
||||
return &AdminHandler{svc: svc, staff: staff}
|
||||
}
|
||||
|
||||
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||
func pageParams(r *http.Request) (q string, limit, offset int) {
|
||||
@@ -316,7 +324,22 @@ func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
|
||||
target := chi.URLParam(r, "id")
|
||||
// A banned account loses panel access, so banning a staff member is a way
|
||||
// to remove them — which only superadmins may do. Without this, an Admin
|
||||
// holding users:ban could ban a Superadmin out of the panel.
|
||||
if _, err := h.staff.Access(r.Context(), target); err == nil {
|
||||
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
|
||||
response.Error(w, http.StatusForbidden,
|
||||
"staff accounts are managed from the Staff page by a superadmin")
|
||||
return
|
||||
}
|
||||
if target == middleware.UserID(r.Context()) {
|
||||
response.Error(w, http.StatusConflict, "you can't ban yourself")
|
||||
return
|
||||
}
|
||||
}
|
||||
ok, err := h.svc.SetUserBanned(r.Context(), target, banned)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,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})
|
||||
}
|
||||
@@ -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 {
|
||||
Auth string `json:"auth"`
|
||||
ChannelData string `json:"channel_data,omitempty"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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
|
||||
// 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) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// FeedbackCategoryHandler serves the /feedback form's categories: the
|
||||
// public list, and their management in the admin panel.
|
||||
type FeedbackCategoryHandler struct {
|
||||
svc *service.FeedbackCategoryService
|
||||
}
|
||||
|
||||
func NewFeedbackCategoryHandler(svc *service.FeedbackCategoryService) *FeedbackCategoryHandler {
|
||||
return &FeedbackCategoryHandler{svc: svc}
|
||||
}
|
||||
|
||||
func writeCategoryError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrFeedbackCategoryNotFound):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, repository.ErrFeedbackCategoryInUse),
|
||||
errors.Is(err, repository.ErrFeedbackCategoryExists),
|
||||
errors.Is(err, service.ErrCategoryLastActive):
|
||||
response.Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrCategoryLabel), errors.Is(err, service.ErrCategoryDescription):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "feedback category request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Public — GET /feedback/categories: the form's options, in order.
|
||||
func (h *FeedbackCategoryHandler) Public(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.Active(r.Context())
|
||||
if err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// List — GET /admin/feedback/categories: all, with usage counts.
|
||||
func (h *FeedbackCategoryHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.All(r.Context())
|
||||
if err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type categoryReq struct {
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
func (h *FeedbackCategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req categoryReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
c, err := h.svc.Create(r.Context(), req.Label, req.Description)
|
||||
if err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (h *FeedbackCategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var req categoryReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Update(r.Context(), chi.URLParam(r, "key"), req.Label, req.Description, req.Archived); err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FeedbackCategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "key")); err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Reorder — PUT /admin/feedback/categories/order {"keys": [...]}.
|
||||
func (h *FeedbackCategoryHandler) Reorder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Keys) == 0 {
|
||||
response.Error(w, http.StatusBadRequest, "keys are required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Reorder(r.Context(), req.Keys); err != nil {
|
||||
writeCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// QuizHandler serves quiz authoring (admin) and quiz taking (users).
|
||||
type QuizHandler struct {
|
||||
svc *service.QuizService
|
||||
}
|
||||
|
||||
func NewQuizHandler(svc *service.QuizService) *QuizHandler { return &QuizHandler{svc: svc} }
|
||||
|
||||
func writeQuizError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrQuizNotFound), errors.Is(err, service.ErrQuizUnpublished):
|
||||
response.Error(w, http.StatusNotFound, "quiz not found")
|
||||
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
|
||||
response.Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
|
||||
errors.Is(err, service.ErrQuizLocked), errors.Is(err, repository.ErrQuizTargetTaken):
|
||||
response.Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
|
||||
errors.Is(err, service.ErrQuizNoCorrect),
|
||||
errors.Is(err, service.ErrQuizTitle), errors.Is(err, service.ErrQuizNoQuestions),
|
||||
errors.Is(err, service.ErrQuizTooMany), errors.Is(err, service.ErrQuizPrompt),
|
||||
errors.Is(err, service.ErrQuizOptionCount), errors.Is(err, service.ErrQuizOptionBody),
|
||||
errors.Is(err, service.ErrQuizOneCorrect), errors.Is(err, service.ErrQuizTarget):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "quiz request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type quizOptionReq struct {
|
||||
Body string `json:"body"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
}
|
||||
|
||||
type quizQuestionReq struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Explanation string `json:"explanation"`
|
||||
Multi bool `json:"multi"`
|
||||
Options []quizOptionReq `json:"options"`
|
||||
}
|
||||
|
||||
type quizReq struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TargetURL string `json:"target_url"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
Questions []quizQuestionReq `json:"questions"`
|
||||
}
|
||||
|
||||
func (q quizReq) draft() service.QuizDraft {
|
||||
d := service.QuizDraft{
|
||||
Title: q.Title, Description: q.Description,
|
||||
TargetURL: q.TargetURL, TargetTitle: q.TargetTitle,
|
||||
}
|
||||
for _, qq := range q.Questions {
|
||||
in := repository.QuestionInput{Prompt: qq.Prompt, Explanation: qq.Explanation, Multi: qq.Multi}
|
||||
for _, o := range qq.Options {
|
||||
in.Options = append(in.Options, repository.OptionInput{Body: o.Body, IsCorrect: o.IsCorrect})
|
||||
}
|
||||
d.Questions = append(d.Questions, in)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *QuizHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.ListAdmin(r.Context())
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) AdminGet(w http.ResponseWriter, r *http.Request) {
|
||||
q, err := h.svc.GetAdmin(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, q)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req quizReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
q, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), req.draft())
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, q)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var req quizReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
q, err := h.svc.Update(r.Context(), chi.URLParam(r, "id"), req.draft())
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, q)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) setPublished(w http.ResponseWriter, r *http.Request, on bool) {
|
||||
q, err := h.svc.SetPublished(r.Context(), chi.URLParam(r, "id"), on)
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, q)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Publish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
|
||||
func (h *QuizHandler) Unpublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, false) }
|
||||
|
||||
func (h *QuizHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Participants(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.Participants(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Responses(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.svc.Responses(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.svc.ParticipantResult(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
|
||||
// that person's attempt so the quiz is pending for them again.
|
||||
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
|
||||
// page with the caller's progress, or null when the page has none.
|
||||
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
|
||||
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, card)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.ListPublished(r.Context(), middleware.UserID(r.Context()))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
card, err := h.svc.Card(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, card)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
play, err := h.svc.Start(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, play)
|
||||
}
|
||||
|
||||
func (h *QuizHandler) Answer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
OptionIDs []string `json:"option_ids"`
|
||||
// Single-option form, still accepted from older clients.
|
||||
OptionID string `json:"option_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.QuestionID == "" {
|
||||
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
|
||||
return
|
||||
}
|
||||
if len(req.OptionIDs) == 0 && req.OptionID != "" {
|
||||
req.OptionIDs = []string{req.OptionID}
|
||||
}
|
||||
if len(req.OptionIDs) == 0 {
|
||||
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
|
||||
return
|
||||
}
|
||||
play, err := h.svc.Answer(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), req.QuestionID, req.OptionIDs)
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, play)
|
||||
}
|
||||
|
||||
// Result — GET /quizzes/{id}/result. The caller's own; the user id is taken
|
||||
// from the session, so there is no way to ask for anyone else's.
|
||||
func (h *QuizHandler) Result(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.svc.Result(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeQuizError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, res)
|
||||
}
|
||||
@@ -2,11 +2,15 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
@@ -114,6 +118,110 @@ func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
|
||||
// client has shown the confetti, so a gift surfaces exactly once.
|
||||
// OpenGift — POST /sadhna/rewards/gifts/{key}/open
|
||||
//
|
||||
// Called after the celebration is dismissed. Idempotent: opening an already
|
||||
// opened gift changes nothing.
|
||||
func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.OpenGift(r.Context(),
|
||||
middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not open that gift")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GiftableRewards — GET /admin/reward-gifts/catalog
|
||||
func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.GiftableRewards(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load the catalog")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// DeleteCustomGift — DELETE /admin/reward-gifts/{key}
|
||||
func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil {
|
||||
if errors.Is(err, repository.ErrRewardNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "no such custom gift")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type customGiftReq struct {
|
||||
Name string `json:"name"`
|
||||
Meaning string `json:"meaning"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
// CreateCustomGift — POST /admin/reward-gifts/catalog
|
||||
//
|
||||
// Adds a sticker to the catalog. It reaches nobody until it is sent.
|
||||
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req customGiftReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, reward)
|
||||
}
|
||||
|
||||
// PresignGiftImage — POST /admin/reward-gifts/image-presign
|
||||
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]string{
|
||||
"upload_url": upload, "image_url": public,
|
||||
})
|
||||
}
|
||||
|
||||
type giftReq struct {
|
||||
RewardKey string `json:"reward_key"`
|
||||
}
|
||||
|
||||
// GiftToEveryone — POST /admin/reward-gifts
|
||||
//
|
||||
// Hands one reward to every account, unopened. Idempotent, so a repeated
|
||||
// click tops up only the people who don't have it rather than duplicating.
|
||||
func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) {
|
||||
var req giftReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" {
|
||||
response.Error(w, http.StatusBadRequest, "reward_key is required")
|
||||
return
|
||||
}
|
||||
n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrRewardNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "no such reward")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "could not send the gift")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]int64{"granted": n})
|
||||
}
|
||||
|
||||
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
|
||||
|
||||
@@ -82,13 +82,18 @@ const searchStopwordsSet = "common"
|
||||
|
||||
// searchResultItem represents a single search result in the API response.
|
||||
type searchResultItem struct {
|
||||
Collection string `json:"collection"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
URL string `json:"url"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
score int64 // internal, not serialized
|
||||
Collection string `json:"collection"`
|
||||
// DocID is the underlying Mongo _id hex — the indexer uses it as the
|
||||
// Typesense document id. Surfaced so the admin panel can pin a specific
|
||||
// item as the Vāṇī of the Day; it identifies public content, not anything
|
||||
// private.
|
||||
DocID string `json:"doc_id,omitempty"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
URL string `json:"url"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
score int64 // internal, not serialized
|
||||
}
|
||||
|
||||
// searchResponse is the shape of the data field in the API response.
|
||||
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
|
||||
|
||||
item := searchResultItem{
|
||||
Collection: collection,
|
||||
DocID: getStringField(doc, "id"),
|
||||
URL: getStringField(doc, "url"),
|
||||
score: score,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -41,11 +42,29 @@ type SoketiWebhookHandler struct {
|
||||
appKey string
|
||||
appSecret string
|
||||
feed *repository.FeedRepo
|
||||
logger *zap.Logger
|
||||
// chat records presence so a message doesn't push to someone who is
|
||||
// already looking at the app. Optional.
|
||||
chat ChatPresence
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler {
|
||||
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger}
|
||||
// ChatPresence is the slice of ChatService this handler needs — narrow so the
|
||||
// webhook doesn't depend on the whole chat surface.
|
||||
type ChatPresence interface {
|
||||
MarkPresence(ctx context.Context, userID string, online bool)
|
||||
}
|
||||
|
||||
func (h *SoketiWebhookHandler) presence(ctx context.Context, userID string, online bool) {
|
||||
if h.chat == nil {
|
||||
return
|
||||
}
|
||||
h.chat.MarkPresence(ctx, userID, online)
|
||||
}
|
||||
|
||||
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, chat ChatPresence, logger *zap.Logger) *SoketiWebhookHandler {
|
||||
return &SoketiWebhookHandler{
|
||||
appKey: appKey, appSecret: appSecret, feed: feed, chat: chat, logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type soketiWebhookBody struct {
|
||||
@@ -91,12 +110,20 @@ func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
cleared := 0
|
||||
for _, e := range payload.Events {
|
||||
// A per-user notifications channel emptying = that user is offline.
|
||||
if e.Name != "channel_vacated" {
|
||||
uid, ok := userIDFromNotifChannel(e.Channel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if uid, ok := userIDFromNotifChannel(e.Channel); ok {
|
||||
switch e.Name {
|
||||
case "channel_occupied":
|
||||
// This user has a live connection, so chat push is suppressed
|
||||
// for them — they will see the message in the app.
|
||||
h.presence(ctx, uid, true)
|
||||
case "channel_vacated":
|
||||
// Their last tab closed: they are offline. Evict the feed
|
||||
// seen-set and let chat push resume.
|
||||
h.feed.Reset(ctx, uid)
|
||||
h.presence(ctx, uid, false)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
|
||||
type StaffHandler struct {
|
||||
svc *service.StaffService
|
||||
}
|
||||
|
||||
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
|
||||
|
||||
func writeStaffError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
|
||||
errors.Is(err, repository.ErrUserGone):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
|
||||
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
|
||||
errors.Is(err, repository.ErrRoleKeyTaken):
|
||||
response.Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
|
||||
errors.Is(err, service.ErrRoleNoPermissions):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "staff request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Me — GET /admin/me. The caller's role and effective permissions, which the
|
||||
// panel uses to hide what they can't do. Hiding is a courtesy: every route
|
||||
// enforces its own permission regardless.
|
||||
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
s := middleware.Staff(r.Context())
|
||||
response.JSON(w, http.StatusOK, service.Me{
|
||||
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
|
||||
})
|
||||
}
|
||||
|
||||
// Catalog — GET /admin/permissions. What a custom role may be built from.
|
||||
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
|
||||
response.JSON(w, http.StatusOK, rbac.Catalog)
|
||||
}
|
||||
|
||||
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type grantReq struct {
|
||||
Handle string `json:"handle"`
|
||||
RoleID string `json:"role_id"`
|
||||
}
|
||||
|
||||
// Grant — POST /admin/staff
|
||||
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
|
||||
var req grantReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
|
||||
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// ChangeRole — PATCH /admin/staff/{id}
|
||||
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RoleID string `json:"role_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
|
||||
response.Error(w, http.StatusBadRequest, "role_id is required")
|
||||
return
|
||||
}
|
||||
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// Revoke — DELETE /admin/staff/{id}
|
||||
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
|
||||
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
|
||||
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
|
||||
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
|
||||
|
||||
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
|
||||
roles, err := h.svc.Roles(r.Context())
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, roles)
|
||||
}
|
||||
|
||||
type adminRoleReq struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req adminRoleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, ro)
|
||||
}
|
||||
|
||||
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req adminRoleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
|
||||
if err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, ro)
|
||||
}
|
||||
|
||||
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||
writeStaffError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
@@ -18,6 +25,125 @@ func NewVaniHandler(svc *service.VaniService) *VaniHandler {
|
||||
|
||||
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
||||
// the current UTC day. Responds with `data: null` if there's no content.
|
||||
type setVaniReq struct {
|
||||
// Day as YYYY-MM-DD (UTC). Empty means today.
|
||||
Day string `json:"day"`
|
||||
Kind string `json:"kind"`
|
||||
// RefID is the content's Mongo _id hex, which search returns as doc_id.
|
||||
RefID string `json:"ref_id"`
|
||||
}
|
||||
|
||||
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
|
||||
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
|
||||
var req setVaniReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
day, err := parseVaniDay(req.Day)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
|
||||
if err != nil {
|
||||
writeVaniError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"day": pick.Day.Format("2006-01-02"),
|
||||
"kind": pick.Kind,
|
||||
"ref_id": pick.RefID,
|
||||
"payload": pick.Payload,
|
||||
})
|
||||
}
|
||||
|
||||
type setVaniByURLReq struct {
|
||||
Day string `json:"day"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
|
||||
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
|
||||
var req setVaniByURLReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
day, err := parseVaniDay(req.Day)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
|
||||
if err != nil {
|
||||
writeVaniError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"day": pick.Day.Format("2006-01-02"),
|
||||
"kind": pick.Kind,
|
||||
"ref_id": pick.RefID,
|
||||
"payload": pick.Payload,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
|
||||
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
|
||||
day, err := parseVaniDay(chi.URLParam(r, "day"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
if err := h.svc.ClearForDay(r.Context(), day); err != nil {
|
||||
writeVaniError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Schedule — GET /admin/vani. Decided days from today onward.
|
||||
func (h *VaniHandler) Schedule(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
picks, err := h.svc.Schedule(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeVaniError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(picks))
|
||||
for _, p := range picks {
|
||||
out = append(out, map[string]any{
|
||||
"day": p.Day.Format("2006-01-02"),
|
||||
"kind": p.Kind,
|
||||
"ref_id": p.RefID,
|
||||
"payload": p.Payload,
|
||||
})
|
||||
}
|
||||
response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// parseVaniDay accepts YYYY-MM-DD, or empty for today (UTC — the pick is
|
||||
// keyed on a UTC calendar day so everyone sees the same item).
|
||||
func parseVaniDay(s string) (time.Time, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Now().UTC().Truncate(24 * time.Hour), nil
|
||||
}
|
||||
return time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func writeVaniError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrVaniRefInvalid),
|
||||
errors.Is(err, service.ErrVaniBadKind),
|
||||
errors.Is(err, service.ErrVaniBadURL):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, repository.ErrVaniRefNotFound):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "vāṇī request failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := h.svc.Today(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
type staffCtxKey struct{}
|
||||
|
||||
// StaffContext is the caller's resolved admin access, set by RequireStaff.
|
||||
type StaffContext struct {
|
||||
RoleKey string
|
||||
RoleName string
|
||||
Perms rbac.Set
|
||||
}
|
||||
|
||||
// Staff returns the caller's staff context, or nil outside RequireStaff.
|
||||
func Staff(ctx context.Context) *StaffContext {
|
||||
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
|
||||
return s
|
||||
}
|
||||
|
||||
// RequireStaff admits accounts that hold an admin role and aren't blocked from
|
||||
// the panel. It MUST run after RequireAuth.
|
||||
//
|
||||
// It resolves the role's permissions once per request and puts them in
|
||||
// context, so the RequirePermission checks on each route are map lookups
|
||||
// rather than further queries. Being staff gets you through the door only;
|
||||
// every route then names the permission it needs.
|
||||
func RequireStaff(staff *repository.StaffRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid := UserID(r.Context())
|
||||
@@ -19,13 +40,37 @@ func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := users.FindByID(r.Context(), uid)
|
||||
access, err := staff.Access(r.Context(), uid)
|
||||
if errors.Is(err, repository.ErrNotStaff) {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||
return
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
if access.Blocked {
|
||||
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
|
||||
RoleKey: access.RoleKey,
|
||||
RoleName: access.RoleName,
|
||||
Perms: rbac.NewSet(access.Permissions),
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission gates a route to staff holding perm. Must run inside
|
||||
// RequireStaff; outside it there is no staff context and everything is refused.
|
||||
func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := Staff(r.Context())
|
||||
if s == nil || !s.Perms.Has(perm) {
|
||||
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
)
|
||||
|
||||
func withStaff(perms ...string) context.Context {
|
||||
return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{
|
||||
RoleKey: "x", Perms: rbac.NewSet(perms),
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequirePermission(t *testing.T) {
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
gate := RequirePermission("users:ban")(ok)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
want int
|
||||
}{
|
||||
{"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden},
|
||||
{"admin with it", withStaff("users:ban"), http.StatusNoContent},
|
||||
{"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent},
|
||||
// Outside RequireStaff there's no staff context at all: refuse.
|
||||
{"no staff context", context.Background(), http.StatusForbidden},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx))
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Staff management is never reachable through a catalog permission.
|
||||
if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) {
|
||||
t.Error("a non-wildcard set holds admins:manage")
|
||||
}
|
||||
}
|
||||
|
||||
func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) }
|
||||
+20
-17
@@ -17,8 +17,10 @@ type AdminUser struct {
|
||||
Plan string `json:"plan"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
BannedAt *time.Time `json:"banned_at"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// AdminRole is the staff role key, nil for ordinary accounts.
|
||||
AdminRole *string `json:"admin_role,omitempty"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Connected auth methods. HasPassword means email+password login is set up;
|
||||
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||
@@ -56,18 +58,18 @@ type AdminDisciple struct {
|
||||
|
||||
// AdminPost is a row in the admin posts table.
|
||||
type AdminPost struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Caption string `json:"caption"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Caption string `json:"caption"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
SourceURL string `json:"source_url"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
}
|
||||
|
||||
// AdminCollection is a row in the admin collections table.
|
||||
@@ -85,18 +87,19 @@ type AdminCollection struct {
|
||||
|
||||
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||
type AdminFeedback struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
CategoryLabel string `json:"category_label"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
RepliedAt *time.Time `json:"replied_at"`
|
||||
ReplyMessage *string `json:"reply_message"`
|
||||
AuthorHandle *string `json:"author_handle"`
|
||||
AuthorName *string `json:"author_name"`
|
||||
AuthorEmail *string `json:"author_email"`
|
||||
ReplyMessage *string `json:"reply_message"`
|
||||
AuthorHandle *string `json:"author_handle"`
|
||||
AuthorName *string `json:"author_name"`
|
||||
AuthorEmail *string `json:"author_email"`
|
||||
}
|
||||
|
||||
// AdminReport is a unified projection over the three report tables
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -18,7 +18,10 @@ type Feedback struct {
|
||||
ID string `json:"id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
// CategoryLabel is the category's current display name (falls back to
|
||||
// the key if the category was ever removed).
|
||||
CategoryLabel string `json:"category_label"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
|
||||
@@ -50,3 +53,15 @@ type Feedback struct {
|
||||
AuthorName *string `json:"author_name,omitempty"`
|
||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||
}
|
||||
|
||||
// FeedbackCategory is one option on the /feedback form, managed from the
|
||||
// admin panel. Key is what feedback.category stores and never changes.
|
||||
type FeedbackCategory struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
// UsageCount is admin-only: how much feedback is filed under it.
|
||||
UsageCount int `json:"usage_count,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Quiz target kinds.
|
||||
const (
|
||||
QuizChapter = "chapter"
|
||||
QuizLecture = "lecture"
|
||||
QuizConversation = "conversation"
|
||||
QuizStandalone = "standalone"
|
||||
)
|
||||
|
||||
// Attempt states, as seen by the person taking the quiz.
|
||||
const (
|
||||
QuizNotStarted = "not_started"
|
||||
QuizInProgress = "in_progress"
|
||||
QuizCompleted = "completed"
|
||||
)
|
||||
|
||||
// Quiz is a set of multiple-choice questions, optionally tied to a page.
|
||||
type Quiz struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetPath *string `json:"target_path,omitempty"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
// TargetYear is the lecture or conversation's year, for grouping.
|
||||
TargetYear *int `json:"target_year,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
QuestionCount int `json:"question_count"`
|
||||
// Admin-only counters.
|
||||
AttemptCount int `json:"attempt_count,omitempty"`
|
||||
CompletedCount int `json:"completed_count,omitempty"`
|
||||
|
||||
Questions []QuizQuestion `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
// QuizQuestion is one question. Prompt and option bodies carry the same
|
||||
// formatting markers as the rest of the admin text.
|
||||
type QuizQuestion struct {
|
||||
ID string `json:"id"`
|
||||
Ord int `json:"ord"`
|
||||
Prompt string `json:"prompt"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
// Multi questions ask for every correct option ("select all that apply").
|
||||
Multi bool `json:"multi"`
|
||||
Options []QuizOption `json:"options"`
|
||||
}
|
||||
|
||||
// QuizOption is one choice. IsCorrect is nil whenever the reader must not
|
||||
// know it yet — every response to someone mid-attempt.
|
||||
type QuizOption struct {
|
||||
ID string `json:"id"`
|
||||
Ord int `json:"ord"`
|
||||
Body string `json:"body"`
|
||||
IsCorrect *bool `json:"is_correct,omitempty"`
|
||||
}
|
||||
|
||||
// QuizStatus is the caller's progress on a quiz.
|
||||
type QuizStatus struct {
|
||||
State string `json:"state"`
|
||||
Answered int `json:"answered"`
|
||||
Total int `json:"total"`
|
||||
Score *int `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// QuizCard is a quiz plus the caller's progress, for buttons and lists.
|
||||
type QuizCard struct {
|
||||
Quiz
|
||||
My QuizStatus `json:"my"`
|
||||
}
|
||||
|
||||
// QuizPlay is the next step of an attempt: the current question, or Done.
|
||||
type QuizPlay struct {
|
||||
QuizID string `json:"quiz_id"`
|
||||
Title string `json:"title"`
|
||||
Answered int `json:"answered"`
|
||||
Total int `json:"total"`
|
||||
Done bool `json:"done"`
|
||||
Question *QuizQuestion `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// QuizResultItem is one question on a results screen.
|
||||
type QuizResultItem struct {
|
||||
QuizQuestion
|
||||
ChosenOptionIDs []string `json:"chosen_option_ids"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
ResponseMs *int `json:"response_ms,omitempty"`
|
||||
}
|
||||
|
||||
// QuizResult is a finished attempt.
|
||||
type QuizResult struct {
|
||||
Quiz Quiz `json:"quiz"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
TotalMs int `json:"total_ms"`
|
||||
Items []QuizResultItem `json:"items"`
|
||||
}
|
||||
|
||||
// QuizParticipant is one person's attempt, for the admin participants list.
|
||||
type QuizParticipant struct {
|
||||
UserID string `json:"user_id"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Answered int `json:"answered"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
TotalMs int `json:"total_ms"`
|
||||
}
|
||||
|
||||
// QuizOptionStat is how often one option was chosen.
|
||||
type QuizOptionStat struct {
|
||||
QuizOption
|
||||
Picks int `json:"picks"`
|
||||
}
|
||||
|
||||
// QuizQuestionStat aggregates every response to one question.
|
||||
type QuizQuestionStat struct {
|
||||
ID string `json:"id"`
|
||||
Ord int `json:"ord"`
|
||||
Prompt string `json:"prompt"`
|
||||
Multi bool `json:"multi"`
|
||||
Answered int `json:"answered"`
|
||||
Correct int `json:"correct"`
|
||||
AvgMs int `json:"avg_ms"`
|
||||
MedianMs int `json:"median_ms"`
|
||||
Options []QuizOptionStat `json:"options"`
|
||||
}
|
||||
@@ -52,9 +52,16 @@ type SadhnaReward struct {
|
||||
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
|
||||
// multiple of 7 days). The full reward is embedded for direct rendering.
|
||||
type SadhnaRewardGrant struct {
|
||||
Milestone int `json:"milestone"`
|
||||
// Milestone is the streak day this was earned on. Nil for an admin gift,
|
||||
// which isn't tied to a streak at all.
|
||||
Milestone *int `json:"milestone"`
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
Reward SadhnaReward `json:"reward"`
|
||||
// Source is "streak" or "gift".
|
||||
Source string `json:"source"`
|
||||
// Opened is false while a gift is still wrapped — the gallery renders
|
||||
// those blurred until the devotee opens them.
|
||||
Opened bool `json:"opened"`
|
||||
}
|
||||
|
||||
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It
|
||||
|
||||
@@ -183,6 +183,15 @@ type RelationshipState struct {
|
||||
RequestsYou bool `json:"requests_you"`
|
||||
Blocked bool `json:"blocked"`
|
||||
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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AdminRole is a named set of admin-panel permissions.
|
||||
type AdminRole struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
Permissions []string `json:"permissions"`
|
||||
// MemberCount is how many staff hold the role, for the roles screen.
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// StaffMember is one account with admin-panel access.
|
||||
type StaffMember struct {
|
||||
UserID string `json:"user_id"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
RoleID string `json:"role_id"`
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleName string `json:"role_name"`
|
||||
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||
}
|
||||
|
||||
// StaffAccess is what the admin middleware needs about the caller.
|
||||
type StaffAccess struct {
|
||||
RoleKey string
|
||||
RoleName string
|
||||
Permissions []string
|
||||
Blocked bool
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package rbac is the admin panel's permission catalog.
|
||||
//
|
||||
// Permissions are "resource:action" strings. The catalog lives in code, not in
|
||||
// the database, so a permission can't exist without a route enforcing it, and
|
||||
// adding one is a reviewed change. Roles (in admin_roles) are just named sets
|
||||
// of these strings.
|
||||
package rbac
|
||||
|
||||
import "slices"
|
||||
|
||||
// Wildcard grants everything. Only the seeded superadmin role carries it.
|
||||
const Wildcard = "*"
|
||||
|
||||
// ManageStaff is held by superadmins alone: granting, changing, revoking and
|
||||
// blocking other staff. It is deliberately absent from Catalog, so no custom
|
||||
// role can ever be given it.
|
||||
const ManageStaff = "admins:manage"
|
||||
|
||||
// Permission is one grantable capability.
|
||||
type Permission struct {
|
||||
Key string `json:"key"`
|
||||
Group string `json:"group"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// Catalog is every permission a custom role may hold, grouped for the editor.
|
||||
var Catalog = []Permission{
|
||||
{"stats:read", "Overview", "View dashboard stats"},
|
||||
|
||||
{"users:read", "Users", "View users"},
|
||||
{"users:notify", "Users", "Send users a message"},
|
||||
{"users:ban", "Users", "Ban and unban users"},
|
||||
{"users:limits", "Users", "Change bookmark and collection limits"},
|
||||
{"disciples:manage", "Users", "Link and unlink direct disciples"},
|
||||
|
||||
{"posts:moderate", "Moderation", "Hide, delete and restore posts"},
|
||||
{"comments:moderate", "Moderation", "Delete and restore comments"},
|
||||
{"collections:read", "Moderation", "View collections"},
|
||||
{"reports:read", "Moderation", "View reports"},
|
||||
{"reports:resolve", "Moderation", "Resolve reports"},
|
||||
|
||||
{"feedback:read", "Feedback", "View feedback"},
|
||||
{"feedback:reply", "Feedback", "Reply to feedback"},
|
||||
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
|
||||
{"feedback:categories", "Feedback", "Manage feedback categories"},
|
||||
|
||||
{"vani:read", "Vāṇī of the Day", "View the schedule"},
|
||||
{"vani:write", "Vāṇī of the Day", "Pin and clear days"},
|
||||
|
||||
{"rewards:read", "Rewards", "View the reward catalog"},
|
||||
{"rewards:write", "Rewards", "Add custom gifts"},
|
||||
{"rewards:send", "Rewards", "Send gifts to everyone"},
|
||||
{"rewards:delete", "Rewards", "Delete custom gifts"},
|
||||
|
||||
{"broadcasts:read", "Announcements", "View announcements"},
|
||||
{"broadcasts:write", "Announcements", "Create and delete drafts"},
|
||||
{"broadcasts:publish", "Announcements", "Publish to everyone"},
|
||||
|
||||
{"quizzes:read", "Quizzes", "View quizzes"},
|
||||
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
|
||||
{"quizzes:delete", "Quizzes", "Delete quizzes"},
|
||||
{"quizzes:results", "Quizzes", "View participants and responses"},
|
||||
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
|
||||
|
||||
{"short_urls:read", "Short URLs", "View short links and stats"},
|
||||
}
|
||||
|
||||
// Valid reports whether p is a grantable permission.
|
||||
func Valid(p string) bool {
|
||||
return slices.ContainsFunc(Catalog, func(c Permission) bool { return c.Key == p })
|
||||
}
|
||||
|
||||
// Set is a resolved permission set for one staff member.
|
||||
type Set struct {
|
||||
all bool
|
||||
keys map[string]bool
|
||||
}
|
||||
|
||||
// NewSet resolves a role's stored permissions.
|
||||
func NewSet(perms []string) Set {
|
||||
s := Set{keys: map[string]bool{}}
|
||||
for _, p := range perms {
|
||||
if p == Wildcard {
|
||||
s.all = true
|
||||
}
|
||||
s.keys[p] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Has reports whether the set grants p.
|
||||
func (s Set) Has(p string) bool { return s.all || s.keys[p] }
|
||||
|
||||
// List is the effective permissions, for the admin UI to decide what to show.
|
||||
// A wildcard expands to the full catalog plus ManageStaff.
|
||||
func (s Set) List() []string {
|
||||
if s.all {
|
||||
out := make([]string, 0, len(Catalog)+1)
|
||||
for _, c := range Catalog {
|
||||
out = append(out, c.Key)
|
||||
}
|
||||
return append(out, ManageStaff)
|
||||
}
|
||||
out := make([]string, 0, len(s.keys))
|
||||
for k := range s.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||
u.disciple_id,
|
||||
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users u
|
||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||
@@ -62,7 +63,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||
&u.HasPassword, &u.Providers,
|
||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
||||
&u.DiscipleID, &total); err != nil {
|
||||
&u.DiscipleID, &u.AdminRole, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||
}
|
||||
// Resolve plan default + overrides so the panel shows what's in force.
|
||||
@@ -247,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
||||
status = "all"
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
||||
SELECT f.id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
|
||||
u.handle, u.name, u.email,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||
WHERE ($1 = 'all'
|
||||
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
||||
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
||||
@@ -267,7 +269,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var f model.AdminFeedback
|
||||
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
if err := rows.Scan(&f.ID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeedbackCategoryNotFound = errors.New("feedback category not found")
|
||||
ErrFeedbackCategoryInUse = errors.New("feedback is filed under this category; archive it instead")
|
||||
ErrFeedbackCategoryExists = errors.New("a category with that name already exists")
|
||||
)
|
||||
|
||||
// FeedbackCategoryRepo stores the /feedback form's categories.
|
||||
type FeedbackCategoryRepo struct{ pg *Postgres }
|
||||
|
||||
func NewFeedbackCategoryRepo(pg *Postgres) *FeedbackCategoryRepo {
|
||||
return &FeedbackCategoryRepo{pg: pg}
|
||||
}
|
||||
|
||||
// List returns categories in form order. includeArchived is true for the
|
||||
// admin (which also gets usage counts), false for the public form.
|
||||
func (r *FeedbackCategoryRepo) List(ctx context.Context, includeArchived bool) ([]model.FeedbackCategory, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT c.key, c.label, c.description, c.sort_order, c.archived_at,
|
||||
CASE WHEN $1 THEN (SELECT count(*) FROM feedback f WHERE f.category = c.key) ELSE 0 END
|
||||
FROM feedback_categories c
|
||||
WHERE $1 OR c.archived_at IS NULL
|
||||
ORDER BY (c.archived_at IS NOT NULL), c.sort_order, c.label`, includeArchived)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list feedback categories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.FeedbackCategory{}
|
||||
for rows.Next() {
|
||||
var c model.FeedbackCategory
|
||||
if err := rows.Scan(&c.Key, &c.Label, &c.Description, &c.SortOrder, &c.ArchivedAt, &c.UsageCount); err != nil {
|
||||
return nil, fmt.Errorf("scan feedback category: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsActive reports whether new feedback may be filed under key.
|
||||
func (r *FeedbackCategoryRepo) IsActive(ctx context.Context, key string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1 AND archived_at IS NULL)`,
|
||||
key).Scan(&ok)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check feedback category: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// KeyTaken reports whether a key is already used, archived or not.
|
||||
func (r *FeedbackCategoryRepo) KeyTaken(ctx context.Context, key string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1)`, key).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Create appends a category at the end of the form.
|
||||
func (r *FeedbackCategoryRepo) Create(ctx context.Context, key, label, description string) (*model.FeedbackCategory, error) {
|
||||
c := model.FeedbackCategory{Key: key, Label: label, Description: description}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO feedback_categories (key, label, description, sort_order)
|
||||
VALUES ($1, $2, $3, COALESCE((SELECT max(sort_order) + 1 FROM feedback_categories WHERE archived_at IS NULL), 0))
|
||||
RETURNING sort_order`, key, label, description).Scan(&c.SortOrder)
|
||||
if isUniqueViolation(err, "feedback_categories_pkey") {
|
||||
return nil, ErrFeedbackCategoryExists
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create feedback category: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Update changes a category's wording and archived state. Its key — what
|
||||
// past feedback points at — is never changed.
|
||||
func (r *FeedbackCategoryRepo) Update(ctx context.Context, key, label, description string, archived bool) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE feedback_categories
|
||||
SET label = $2, description = $3,
|
||||
archived_at = CASE WHEN $4 THEN COALESCE(archived_at, now()) ELSE NULL END
|
||||
WHERE key = $1`, key, label, description, archived)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update feedback category: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrFeedbackCategoryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reorder sets the form order to the given keys, first to last.
|
||||
func (r *FeedbackCategoryRepo) Reorder(ctx context.Context, keys []string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reorder: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
for i, k := range keys {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE feedback_categories SET sort_order = $2 WHERE key = $1`, k, i); err != nil {
|
||||
return fmt.Errorf("reorder feedback category: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Delete removes a category nothing has been filed under. One that's in use
|
||||
// can only be archived, so no submission is ever left without a label.
|
||||
func (r *FeedbackCategoryRepo) Delete(ctx context.Context, key string) error {
|
||||
var used bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM feedback WHERE category = $1)`, key).Scan(&used)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check category usage: %w", err)
|
||||
}
|
||||
if used {
|
||||
return ErrFeedbackCategoryInUse
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM feedback_categories c WHERE c.key = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM feedback f WHERE f.category = c.key)`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete feedback category: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Gone already, or feedback arrived between the check and the delete.
|
||||
if ok, _ := r.KeyTaken(ctx, key); ok {
|
||||
return ErrFeedbackCategoryInUse
|
||||
}
|
||||
return ErrFeedbackCategoryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -37,13 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
|
||||
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
|
||||
f := &model.Feedback{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
||||
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||
f.acknowledged_at, f.resolved_at,
|
||||
u.handle, u.name, u.email
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||
WHERE f.id = $1`, id,
|
||||
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
).Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
|
||||
if err != nil {
|
||||
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, category, message, contact_email, created_at,
|
||||
acknowledged_at, resolved_at, replied_at, reply_message
|
||||
FROM feedback
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
|
||||
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message
|
||||
FROM feedback f
|
||||
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||
WHERE f.user_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2`, userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
|
||||
out := []model.Feedback{}
|
||||
for rows.Next() {
|
||||
var f model.Feedback
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
|
||||
return nil, fmt.Errorf("scan user feedback: %w", err)
|
||||
}
|
||||
@@ -132,12 +134,13 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
|
||||
limit = 25
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT f.id, f.user_id, f.category, f.message, f.created_at,
|
||||
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.created_at,
|
||||
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
|
||||
f.acknowledged_at, f.resolved_at,
|
||||
u.handle, u.name
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
LEFT JOIN feedback_categories fc ON fc.key = f.category
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit,
|
||||
)
|
||||
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
|
||||
out := []model.Feedback{}
|
||||
for rows.Next() {
|
||||
var f model.Feedback
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.CreatedAt,
|
||||
&f.HasContactEmail,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||
&f.AuthorHandle, &f.AuthorName); err != nil {
|
||||
|
||||
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
|
||||
END AS rank
|
||||
FROM users u
|
||||
WHERE u.id <> $1
|
||||
-- A suspended account can't be @mentioned into a post.
|
||||
AND u.banned_at IS NULL
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||
ORDER BY rank, u.name, u.id
|
||||
LIMIT $4 OFFSET $5`,
|
||||
|
||||
@@ -160,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted
|
||||
// ids are simply absent from the map.
|
||||
//
|
||||
// Exists so a page of chat messages sharing N posts costs one query instead of
|
||||
// N — the same reason ImagesFor is batched.
|
||||
func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) {
|
||||
out := make(map[string]*model.Post, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := make([]string, 0, len(ids))
|
||||
for rows.Next() {
|
||||
p := &model.Post{}
|
||||
if err := scanPost(rows, p); err != nil {
|
||||
return nil, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
out[p.ID] = p
|
||||
found = append(found, p.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("find posts: %w", err)
|
||||
}
|
||||
|
||||
imgs, err := r.ImagesFor(ctx, found)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, p := range out {
|
||||
p.Images = imgs[id]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByHandle returns the profile feed for a user. Unknown handle →
|
||||
// empty page (matches pre-existing behaviour).
|
||||
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||
|
||||
@@ -54,6 +54,30 @@ func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushS
|
||||
|
||||
// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for
|
||||
// a user, deleting older ones — a soft per-user device cap.
|
||||
// SubscribedUserIDs lists everyone with at least one live push subscription.
|
||||
//
|
||||
// Used by broadcasts, which fan out to the whole user base: iterating users
|
||||
// and asking each for subscriptions would query for the (large) majority who
|
||||
// have never enabled push.
|
||||
func (r *PushRepo) SubscribedUserIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT DISTINCT user_id FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscribed users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan subscribed user: %w", err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error {
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQuizNotFound = errors.New("quiz not found")
|
||||
ErrQuizNotStarted = errors.New("start the quiz first")
|
||||
ErrQuizNotCurrent = errors.New("that isn't the current question")
|
||||
ErrQuizBadOption = errors.New("that option isn't part of this question")
|
||||
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
|
||||
// ErrQuizTargetTaken: that chapter, lecture or conversation already has a
|
||||
// quiz (enforced by the quizzes_one_per_target index).
|
||||
ErrQuizTargetTaken = errors.New("only one quiz is allowed per link")
|
||||
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
|
||||
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
|
||||
)
|
||||
|
||||
type QuizRepo struct{ pg *Postgres }
|
||||
|
||||
func NewQuizRepo(pg *Postgres) *QuizRepo { return &QuizRepo{pg: pg} }
|
||||
|
||||
// QuizInput is a whole quiz as the admin editor submits it.
|
||||
type QuizInput struct {
|
||||
Title string
|
||||
Description string
|
||||
TargetKind string
|
||||
TargetPath *string
|
||||
TargetTitle string
|
||||
TargetYear *int
|
||||
Questions []QuestionInput
|
||||
}
|
||||
|
||||
type QuestionInput struct {
|
||||
Prompt string
|
||||
Explanation string
|
||||
Multi bool
|
||||
Options []OptionInput
|
||||
}
|
||||
|
||||
type OptionInput struct {
|
||||
Body string
|
||||
IsCorrect bool
|
||||
}
|
||||
|
||||
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
|
||||
q.target_title, q.target_year, q.published_at, q.created_at, q.updated_at,
|
||||
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
|
||||
|
||||
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
|
||||
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
|
||||
&q.TargetTitle, &q.TargetYear, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
|
||||
return row.Scan(append(dest, extra...)...)
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *QuizRepo) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`,
|
||||
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id),
|
||||
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id AND a.completed_at IS NOT NULL)
|
||||
FROM quizzes q WHERE q.deleted_at IS NULL
|
||||
ORDER BY q.updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list quizzes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Quiz{}
|
||||
for rows.Next() {
|
||||
var q model.Quiz
|
||||
if err := scanQuiz(rows, &q, &q.AttemptCount, &q.CompletedCount); err != nil {
|
||||
return nil, fmt.Errorf("scan quiz: %w", err)
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get loads a quiz with its questions. revealAnswers controls whether
|
||||
// IsCorrect is filled in: true for the admin editor and results screens,
|
||||
// false for anyone mid-attempt.
|
||||
func (r *QuizRepo) Get(ctx context.Context, id string, revealAnswers bool) (*model.Quiz, error) {
|
||||
var q model.Quiz
|
||||
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
|
||||
FROM quizzes q WHERE q.id = $1 AND q.deleted_at IS NULL`, id), &q)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrQuizNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load quiz: %w", err)
|
||||
}
|
||||
if q.Questions, err = r.questions(ctx, id, revealAnswers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (r *QuizRepo) questions(ctx context.Context, quizID string, reveal bool) ([]model.QuizQuestion, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT qq.id, qq.ord, qq.prompt, qq.explanation, qq.multi,
|
||||
o.id, o.ord, o.body, o.is_correct
|
||||
FROM quiz_questions qq
|
||||
JOIN quiz_options o ON o.question_id = qq.id
|
||||
WHERE qq.quiz_id = $1
|
||||
ORDER BY qq.ord, o.ord`, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load questions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.QuizQuestion{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
qid, prompt, expl, oid, body string
|
||||
qord, oord int
|
||||
multi, correct bool
|
||||
)
|
||||
if err := rows.Scan(&qid, &qord, &prompt, &expl, &multi, &oid, &oord, &body, &correct); err != nil {
|
||||
return nil, fmt.Errorf("scan question: %w", err)
|
||||
}
|
||||
if len(out) == 0 || out[len(out)-1].ID != qid {
|
||||
out = append(out, model.QuizQuestion{ID: qid, Ord: qord, Prompt: prompt, Explanation: expl, Multi: multi})
|
||||
}
|
||||
opt := model.QuizOption{ID: oid, Ord: oord, Body: body}
|
||||
if reveal {
|
||||
c := correct
|
||||
opt.IsCorrect = &c
|
||||
}
|
||||
last := &out[len(out)-1]
|
||||
last.Options = append(last.Options, opt)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Create stores a new quiz, unpublished, with its questions.
|
||||
func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (string, error) {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin quiz tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var id string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, target_year, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, createdBy,
|
||||
).Scan(&id); err != nil {
|
||||
if isUniqueViolation(err, "quizzes_one_per_target") {
|
||||
return "", ErrQuizTargetTaken
|
||||
}
|
||||
return "", fmt.Errorf("create quiz: %w", err)
|
||||
}
|
||||
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Update rewrites a quiz. replaceQuestions is false once anyone has made an
|
||||
// attempt: their answers point at specific questions and options, so those
|
||||
// are frozen and only the title, description and target may change.
|
||||
func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQuestions bool) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin quiz tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
|
||||
target_path = $5, target_title = $6, target_year = $7, updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "quizzes_one_per_target") {
|
||||
return ErrQuizTargetTaken
|
||||
}
|
||||
return fmt.Errorf("update quiz: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrQuizNotFound
|
||||
}
|
||||
if replaceQuestions {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM quiz_questions WHERE quiz_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("clear questions: %w", err)
|
||||
}
|
||||
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []QuestionInput) error {
|
||||
for i, q := range qs {
|
||||
var qid string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO quiz_questions (quiz_id, ord, prompt, explanation, multi)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`, quizID, i, q.Prompt, q.Explanation, q.Multi,
|
||||
).Scan(&qid); err != nil {
|
||||
return fmt.Errorf("insert question: %w", err)
|
||||
}
|
||||
for j, o := range q.Options {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO quiz_options (question_id, ord, body, is_correct)
|
||||
VALUES ($1, $2, $3, $4)`, qid, j, o.Body, o.IsCorrect); err != nil {
|
||||
return fmt.Errorf("insert option: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuizOnTarget returns the live quiz (published or draft) already attached to
|
||||
// a content path, other than exceptID — or "" when the path is free.
|
||||
func (r *QuizRepo) QuizOnTarget(ctx context.Context, path, exceptID string) (id, title string, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT id, title FROM quizzes
|
||||
WHERE target_path = $1 AND deleted_at IS NULL
|
||||
AND ($2 = '' OR id::text <> $2)
|
||||
LIMIT 1`, path, exceptID).Scan(&id, &title)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("quiz on target: %w", err)
|
||||
}
|
||||
return id, title, nil
|
||||
}
|
||||
|
||||
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *QuizRepo) SetPublished(ctx context.Context, id string, published bool) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE quizzes
|
||||
SET published_at = CASE WHEN $2 THEN COALESCE(published_at, now()) ELSE NULL END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id, published)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish quiz: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrQuizNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete hides a quiz everywhere. Soft, so participants' results survive.
|
||||
func (r *QuizRepo) Delete(ctx context.Context, id string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE quizzes SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete quiz: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrQuizNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Participants lists everyone who started a quiz, best scores first.
|
||||
func (r *QuizRepo) Participants(ctx context.Context, quizID string) ([]model.QuizParticipant, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT u.id, u.handle, u.name, u.avatar_url, a.started_at, a.completed_at,
|
||||
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL),
|
||||
a.score, a.total,
|
||||
COALESCE((SELECT sum(x.response_ms) FROM quiz_answers x WHERE x.attempt_id = a.id), 0)
|
||||
FROM quiz_attempts a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
WHERE a.quiz_id = $1
|
||||
ORDER BY (a.completed_at IS NOT NULL) DESC, a.score DESC, a.started_at`, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiz participants: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.QuizParticipant{}
|
||||
for rows.Next() {
|
||||
var p model.QuizParticipant
|
||||
if err := rows.Scan(&p.UserID, &p.Handle, &p.Name, &p.AvatarURL, &p.StartedAt,
|
||||
&p.CompletedAt, &p.Answered, &p.Score, &p.Total, &p.TotalMs); err != nil {
|
||||
return nil, fmt.Errorf("scan participant: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// QuestionStats aggregates every answer per question and option.
|
||||
func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.QuizQuestionStat, error) {
|
||||
qs, err := r.questions(ctx, quizID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT x.question_id, COALESCE(x.option_ids::text[], '{}'), x.is_correct, x.response_ms
|
||||
FROM quiz_answers x
|
||||
JOIN quiz_questions qq ON qq.id = x.question_id
|
||||
WHERE qq.quiz_id = $1 AND x.answered_at IS NOT NULL`, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiz answers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
picks := map[string]int{}
|
||||
type agg struct {
|
||||
answered, correct int
|
||||
times []int
|
||||
}
|
||||
per := map[string]*agg{}
|
||||
for rows.Next() {
|
||||
var qid string
|
||||
var oids []string
|
||||
var correct *bool
|
||||
var ms *int
|
||||
if err := rows.Scan(&qid, &oids, &correct, &ms); err != nil {
|
||||
return nil, fmt.Errorf("scan answer: %w", err)
|
||||
}
|
||||
a := per[qid]
|
||||
if a == nil {
|
||||
a = &agg{}
|
||||
per[qid] = a
|
||||
}
|
||||
a.answered++
|
||||
if correct != nil && *correct {
|
||||
a.correct++
|
||||
}
|
||||
if ms != nil {
|
||||
a.times = append(a.times, *ms)
|
||||
}
|
||||
for _, oid := range oids {
|
||||
picks[oid]++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]model.QuizQuestionStat, 0, len(qs))
|
||||
for _, q := range qs {
|
||||
st := model.QuizQuestionStat{ID: q.ID, Ord: q.Ord, Prompt: q.Prompt, Multi: q.Multi}
|
||||
if a := per[q.ID]; a != nil {
|
||||
st.Answered, st.Correct = a.answered, a.correct
|
||||
if n := len(a.times); n > 0 {
|
||||
sum := 0
|
||||
for _, t := range a.times {
|
||||
sum += t
|
||||
}
|
||||
st.AvgMs = sum / n
|
||||
sort.Ints(a.times)
|
||||
st.MedianMs = a.times[n/2]
|
||||
}
|
||||
}
|
||||
for _, o := range q.Options {
|
||||
st.Options = append(st.Options, model.QuizOptionStat{QuizOption: o, Picks: picks[o.ID]})
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResetAttempt deletes one person's attempt — its answers go with it — so the
|
||||
// quiz is pending for them again and they can take it afresh.
|
||||
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset attempt: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrQuizNotStarted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Taking a quiz ─────────────────────────────────────────────────────────
|
||||
|
||||
// PublishedForPath returns the newest live quiz on a content page, or nil.
|
||||
func (r *QuizRepo) PublishedForPath(ctx context.Context, path string) (*model.Quiz, error) {
|
||||
var q model.Quiz
|
||||
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
|
||||
FROM quizzes q
|
||||
WHERE q.target_path = $1 AND q.deleted_at IS NULL AND q.published_at IS NOT NULL
|
||||
ORDER BY q.published_at DESC LIMIT 1`, path), &q)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiz for path: %w", err)
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// ListPublished returns every live quiz, newest first.
|
||||
func (r *QuizRepo) ListPublished(ctx context.Context) ([]model.Quiz, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`
|
||||
FROM quizzes q
|
||||
WHERE q.deleted_at IS NULL AND q.published_at IS NOT NULL
|
||||
ORDER BY q.published_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list published quizzes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Quiz{}
|
||||
for rows.Next() {
|
||||
var q model.Quiz
|
||||
if err := scanQuiz(rows, &q); err != nil {
|
||||
return nil, fmt.Errorf("scan quiz: %w", err)
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Statuses returns the caller's progress on each of the given quizzes.
|
||||
// Quizzes they haven't touched are simply absent.
|
||||
func (r *QuizRepo) Statuses(ctx context.Context, userID string, quizIDs []string) (map[string]model.QuizStatus, error) {
|
||||
out := map[string]model.QuizStatus{}
|
||||
if userID == "" || len(quizIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT a.quiz_id, a.completed_at IS NOT NULL, a.score, a.total,
|
||||
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL)
|
||||
FROM quiz_attempts a
|
||||
WHERE a.user_id = $1 AND a.quiz_id = ANY($2)`, userID, quizIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiz statuses: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var done bool
|
||||
var score, total, answered int
|
||||
if err := rows.Scan(&id, &done, &score, &total, &answered); err != nil {
|
||||
return nil, fmt.Errorf("scan status: %w", err)
|
||||
}
|
||||
st := model.QuizStatus{State: model.QuizInProgress, Answered: answered, Total: total}
|
||||
if done {
|
||||
st.State = model.QuizCompleted
|
||||
s := score
|
||||
st.Score = &s
|
||||
}
|
||||
out[id] = st
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type attemptRow struct {
|
||||
ID string
|
||||
StartedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Score int
|
||||
Total int
|
||||
}
|
||||
|
||||
func (r *QuizRepo) attempt(ctx context.Context, quizID, userID string) (*attemptRow, error) {
|
||||
var a attemptRow
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT id, started_at, completed_at, score, total
|
||||
FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID,
|
||||
).Scan(&a.ID, &a.StartedAt, &a.CompletedAt, &a.Score, &a.Total)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrQuizNotStarted
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load attempt: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// Start opens the caller's attempt. Idempotent: starting again resumes.
|
||||
func (r *QuizRepo) Start(ctx context.Context, quizID, userID string, total int) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO quiz_attempts (quiz_id, user_id, total) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (quiz_id, user_id) DO NOTHING`, quizID, userID, total)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start attempt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next returns the first unanswered question and stamps the moment it was
|
||||
// served. The stamp is set ONCE: fetching the same question again — a reload,
|
||||
// a second tab — doesn't restart its clock.
|
||||
func (r *QuizRepo) Next(ctx context.Context, quizID, userID string) (*model.QuizPlay, error) {
|
||||
a, err := r.attempt(ctx, quizID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
play := &model.QuizPlay{QuizID: quizID, Total: a.Total}
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND answered_at IS NOT NULL`,
|
||||
a.ID).Scan(&play.Answered); err != nil {
|
||||
return nil, fmt.Errorf("count answered: %w", err)
|
||||
}
|
||||
if a.CompletedAt != nil {
|
||||
play.Done = true
|
||||
return play, nil
|
||||
}
|
||||
|
||||
var qid string
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT qq.id FROM quiz_questions qq
|
||||
WHERE qq.quiz_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM quiz_answers x
|
||||
WHERE x.attempt_id = $2 AND x.question_id = qq.id
|
||||
AND x.answered_at IS NOT NULL)
|
||||
ORDER BY qq.ord LIMIT 1`, quizID, a.ID).Scan(&qid)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Every question answered but not yet marked complete — finish it.
|
||||
if err := r.complete(ctx, a.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
play.Done = true
|
||||
return play, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("next question: %w", err)
|
||||
}
|
||||
if _, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO quiz_answers (attempt_id, question_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, a.ID, qid); err != nil {
|
||||
return nil, fmt.Errorf("stamp question: %w", err)
|
||||
}
|
||||
|
||||
qs, err := r.questions(ctx, quizID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range qs {
|
||||
if qs[i].ID == qid {
|
||||
play.Question = &qs[i]
|
||||
// The explanation discusses the answer; it belongs on the
|
||||
// results screen, not beside the question being answered.
|
||||
play.Question.Explanation = ""
|
||||
}
|
||||
}
|
||||
return play, nil
|
||||
}
|
||||
|
||||
// Answer records the caller's choice on the CURRENT question: one option, or
|
||||
// for a multi question every option they think applies.
|
||||
//
|
||||
// The question must have been served (Next stamped it), which enforces order
|
||||
// and gives the server its own start time. Response time is shown→answered,
|
||||
// both server clocks. An answer can't be changed once given. A multi answer
|
||||
// is correct only when the chosen set equals the correct set exactly.
|
||||
func (r *QuizRepo) Answer(ctx context.Context, quizID, userID, questionID string, optionIDs []string) error {
|
||||
a, err := r.attempt(ctx, quizID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.CompletedAt != nil {
|
||||
return ErrQuizAlreadyAnswer
|
||||
}
|
||||
|
||||
chosen := map[string]bool{}
|
||||
for _, id := range optionIDs {
|
||||
chosen[id] = true
|
||||
}
|
||||
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin answer tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT qq.multi, o.id, o.is_correct FROM quiz_options o
|
||||
JOIN quiz_questions qq ON qq.id = o.question_id
|
||||
WHERE o.question_id = $1 AND qq.quiz_id = $2`, questionID, quizID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check options: %w", err)
|
||||
}
|
||||
var multi bool
|
||||
known, wrongPick, missed := 0, false, false
|
||||
for rows.Next() {
|
||||
var oid string
|
||||
var isCorrect bool
|
||||
if err := rows.Scan(&multi, &oid, &isCorrect); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan option: %w", err)
|
||||
}
|
||||
if chosen[oid] {
|
||||
known++
|
||||
wrongPick = wrongPick || !isCorrect
|
||||
} else if isCorrect {
|
||||
missed = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("check options: %w", err)
|
||||
}
|
||||
if known != len(chosen) {
|
||||
// Something chosen isn't an option of this question (or the question
|
||||
// isn't part of this quiz at all).
|
||||
return ErrQuizBadOption
|
||||
}
|
||||
if len(chosen) == 0 || (!multi && len(chosen) != 1) {
|
||||
return ErrQuizChoiceCount
|
||||
}
|
||||
correct := !wrongPick && !missed
|
||||
ids := make([]string, 0, len(chosen))
|
||||
for _, id := range optionIDs {
|
||||
if chosen[id] {
|
||||
ids = append(ids, id)
|
||||
delete(chosen, id) // keep the first of any duplicate
|
||||
}
|
||||
}
|
||||
|
||||
var answeredAt *time.Time
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT answered_at FROM quiz_answers
|
||||
WHERE attempt_id = $1 AND question_id = $2 FOR UPDATE`, a.ID, questionID).Scan(&answeredAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrQuizNotCurrent
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load answer row: %w", err)
|
||||
}
|
||||
if answeredAt != nil {
|
||||
return ErrQuizAlreadyAnswer
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE quiz_answers
|
||||
SET option_ids = $3::uuid[], answered_at = now(), is_correct = $4,
|
||||
response_ms = GREATEST(0, (EXTRACT(EPOCH FROM (now() - shown_at)) * 1000)::int)
|
||||
WHERE attempt_id = $1 AND question_id = $2`,
|
||||
a.ID, questionID, ids, correct); err != nil {
|
||||
return fmt.Errorf("record answer: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit answer: %w", err)
|
||||
}
|
||||
|
||||
// Finish the attempt once nothing is left unanswered.
|
||||
var remaining int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM quiz_questions qq
|
||||
WHERE qq.quiz_id = $1 AND NOT EXISTS (
|
||||
SELECT 1 FROM quiz_answers x WHERE x.attempt_id = $2
|
||||
AND x.question_id = qq.id AND x.answered_at IS NOT NULL)`,
|
||||
quizID, a.ID).Scan(&remaining); err != nil {
|
||||
return fmt.Errorf("count remaining: %w", err)
|
||||
}
|
||||
if remaining == 0 {
|
||||
return r.complete(ctx, a.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *QuizRepo) complete(ctx context.Context, attemptID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE quiz_attempts SET completed_at = now(),
|
||||
score = (SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND is_correct)
|
||||
WHERE id = $1 AND completed_at IS NULL`, attemptID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete attempt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result returns an attempt with the answers revealed.
|
||||
//
|
||||
// allowIncomplete is false on the user's path, which refuses until the attempt
|
||||
// is finished — otherwise a result would be a way to peek at the answers. The
|
||||
// admin passes true to inspect an attempt still in progress.
|
||||
func (r *QuizRepo) Result(ctx context.Context, quizID, userID string, allowIncomplete bool) (*model.QuizResult, error) {
|
||||
a, err := r.attempt(ctx, quizID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.CompletedAt == nil && !allowIncomplete {
|
||||
return nil, ErrQuizNotFinished
|
||||
}
|
||||
quiz, err := r.Get(ctx, quizID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &model.QuizResult{
|
||||
Score: a.Score, Total: a.Total, StartedAt: a.StartedAt, CompletedAt: a.CompletedAt,
|
||||
}
|
||||
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT question_id, COALESCE(option_ids::text[], '{}'), COALESCE(is_correct, false), response_ms
|
||||
FROM quiz_answers WHERE attempt_id = $1`, a.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load result answers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type ans struct {
|
||||
options []string
|
||||
correct bool
|
||||
ms *int
|
||||
}
|
||||
got := map[string]ans{}
|
||||
for rows.Next() {
|
||||
var qid string
|
||||
var x ans
|
||||
if err := rows.Scan(&qid, &x.options, &x.correct, &x.ms); err != nil {
|
||||
return nil, fmt.Errorf("scan result answer: %w", err)
|
||||
}
|
||||
got[qid] = x
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, q := range quiz.Questions {
|
||||
item := model.QuizResultItem{QuizQuestion: q, ChosenOptionIDs: []string{}}
|
||||
if x, ok := got[q.ID]; ok {
|
||||
item.ChosenOptionIDs, item.IsCorrect, item.ResponseMs = x.options, x.correct, x.ms
|
||||
if x.ms != nil {
|
||||
res.TotalMs += *x.ms
|
||||
}
|
||||
}
|
||||
res.Items = append(res.Items, item)
|
||||
}
|
||||
quiz.Questions = nil
|
||||
res.Quiz = *quiz
|
||||
return res, nil
|
||||
}
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ErrRewardNotFound is an unknown or retired reward key.
|
||||
var ErrRewardNotFound = errors.New("reward not found")
|
||||
|
||||
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
|
||||
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
|
||||
// embedded so the BFF can seed/sync the catalog on startup with no external
|
||||
@@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er
|
||||
// reward embedded for direct rendering.
|
||||
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT g.milestone, g.granted_at,
|
||||
SELECT g.milestone, g.granted_at, g.source, g.celebrated_at IS NOT NULL,
|
||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||
FROM sadhna_reward_grants g
|
||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||
WHERE g.user_id = $1
|
||||
ORDER BY g.granted_at DESC, g.milestone DESC`,
|
||||
-- NULLS LAST so gifts, which have no milestone, sort among the rest
|
||||
-- by date rather than jumping to the top.
|
||||
ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
|
||||
for rows.Next() {
|
||||
var g model.SadhnaRewardGrant
|
||||
if err := rows.Scan(
|
||||
&g.Milestone, &g.GrantedAt,
|
||||
&g.Milestone, &g.GrantedAt, &g.Source, &g.Opened,
|
||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||
); err != nil {
|
||||
@@ -180,8 +187,106 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCustom adds a reward to the catalog from the admin panel.
|
||||
//
|
||||
// Custom entries survive re-seeding: Seed upserts only the keys in its
|
||||
// embedded JSON, and these keys are namespaced away from those.
|
||||
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||
var w model.SadhnaReward
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
|
||||
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
|
||||
VALUES ($1, $2, $3, $4, true, 1000)
|
||||
RETURNING id, key, name, meaning, image_url, active, sort`,
|
||||
key, name, meaning, imageURL,
|
||||
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create custom reward: %w", err)
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// DeleteCustom removes an admin-made reward from the catalog.
|
||||
//
|
||||
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
|
||||
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
|
||||
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
|
||||
// the artwork onto the message rather than referencing the catalog, so history
|
||||
// stays intact.
|
||||
//
|
||||
// Restricted to custom keys. A seeded reward would come straight back on the
|
||||
// next Seed run, and deleting one would rip a streak reward out of the
|
||||
// galleries of everyone who earned it.
|
||||
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete custom reward: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrRewardNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByKey looks up one reward from the catalog.
|
||||
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
|
||||
var w model.SadhnaReward
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT id, key, name, meaning, image_url, active, sort
|
||||
FROM sadhna_rewards WHERE key = $1 AND active`, key,
|
||||
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrRewardNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reward by key: %w", err)
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// GrantGiftToEveryone hands one reward to every account, unopened.
|
||||
//
|
||||
// Idempotent by the partial unique index: re-running adds it only to people
|
||||
// who don't have it, so a mis-click can't create duplicate tiles. Returns how
|
||||
// many accounts it reached.
|
||||
func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO sadhna_reward_grants (user_id, reward_id, source)
|
||||
SELECT u.id, $1, 'gift'
|
||||
FROM users u
|
||||
WHERE u.banned_at IS NULL
|
||||
ON CONFLICT DO NOTHING`, rewardID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("grant gift: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// OpenGift marks one gift as seen, which is what un-blurs its tile.
|
||||
//
|
||||
// Scoped to a single reward rather than clearing everything pending: a devotee
|
||||
// with two gifts waiting should open them one at a time.
|
||||
func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE sadhna_reward_grants g
|
||||
SET celebrated_at = now()
|
||||
FROM sadhna_rewards w
|
||||
WHERE w.id = g.reward_id
|
||||
AND g.user_id = $1 AND w.key = $2
|
||||
AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open gift: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingGrant returns the most recent grant the client hasn't celebrated
|
||||
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
|
||||
//
|
||||
// STREAK grants only. An admin gift is also uncelebrated until opened, but it
|
||||
// waits as a blurred tile in the gallery rather than ambushing the devotee
|
||||
// with confetti the moment they load the page.
|
||||
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
|
||||
var g model.SadhnaRewardGrant
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
@@ -189,7 +294,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
|
||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||
FROM sadhna_reward_grants g
|
||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||
WHERE g.user_id = $1 AND g.celebrated_at IS NULL
|
||||
WHERE g.user_id = $1 AND g.celebrated_at IS NULL AND g.source = 'streak'
|
||||
ORDER BY g.granted_at DESC, g.milestone DESC
|
||||
LIMIT 1`,
|
||||
userID,
|
||||
@@ -198,6 +303,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa
|
||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||
)
|
||||
g.Source = "streak"
|
||||
if err != nil {
|
||||
return nil, nil // no rows / nothing pending
|
||||
}
|
||||
|
||||
@@ -242,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er
|
||||
|
||||
// BlockState reports, in one round-trip, whether a blocked b and whether
|
||||
// b blocked a.
|
||||
// BlockedWith returns the subset of others that viewerID is blocked with in
|
||||
// EITHER direction — the set whose content must be hidden from them.
|
||||
//
|
||||
// The batched counterpart to BlockState, for resolving many authors at once.
|
||||
func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(others))
|
||||
if viewerID == "" || len(others) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END
|
||||
FROM user_blocks
|
||||
WHERE (blocker_id = $1 AND blocked_id = ANY($2))
|
||||
OR (blocked_id = $1 AND blocker_id = ANY($2))`,
|
||||
viewerID, others)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blocked with: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan blocked with: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotStaff = errors.New("not a staff member")
|
||||
ErrRoleNotFound = errors.New("role not found")
|
||||
ErrRoleKeyTaken = errors.New("a role with that name already exists")
|
||||
ErrRoleInUse = errors.New("role is still assigned to staff")
|
||||
)
|
||||
|
||||
// SuperadminKey is the seeded role that holds the wildcard.
|
||||
const SuperadminKey = "superadmin"
|
||||
|
||||
type StaffRepo struct{ pg *Postgres }
|
||||
|
||||
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
|
||||
|
||||
// Access loads the caller's role and permissions. ErrNotStaff when the account
|
||||
// holds no admin role at all.
|
||||
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
|
||||
var a model.StaffAccess
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
|
||||
FROM users u
|
||||
JOIN admin_roles ro ON ro.id = u.admin_role_id
|
||||
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
|
||||
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotStaff
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("staff access: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
const staffSelect = `
|
||||
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
|
||||
ro.id, ro.key, ro.name, u.admin_blocked_at
|
||||
FROM users u
|
||||
JOIN admin_roles ro ON ro.id = u.admin_role_id`
|
||||
|
||||
func scanStaff(row pgx.Row, m *model.StaffMember) error {
|
||||
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
|
||||
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
|
||||
}
|
||||
|
||||
// ListStaff returns every account holding a role, superadmins first.
|
||||
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
|
||||
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list staff: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.StaffMember{}
|
||||
for rows.Next() {
|
||||
var m model.StaffMember
|
||||
if err := scanStaff(rows, &m); err != nil {
|
||||
return nil, fmt.Errorf("scan staff: %w", err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
|
||||
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
|
||||
var m model.StaffMember
|
||||
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotStaff
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("staff member: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// SetRole grants or changes a role. A nil roleID revokes staff access and
|
||||
// clears any block, so a later re-grant starts clean.
|
||||
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET admin_role_id = $2,
|
||||
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, userID, roleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set staff role: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserGone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBlocked suspends or restores a staff member's panel access.
|
||||
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
|
||||
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set staff blocked: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
|
||||
// stops the last one from being demoted, revoked or blocked.
|
||||
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM users u
|
||||
JOIN admin_roles ro ON ro.id = u.admin_role_id
|
||||
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
|
||||
SuperadminKey).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count superadmins: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UserIDByHandle resolves a handle for granting a role.
|
||||
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
|
||||
var id string
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrUserGone
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve handle: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ── Roles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
|
||||
ro.permissions, ro.created_at,
|
||||
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
|
||||
FROM admin_roles ro
|
||||
ORDER BY ro.is_system DESC,
|
||||
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
|
||||
ro.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.AdminRole{}
|
||||
for rows.Next() {
|
||||
var ro model.AdminRole
|
||||
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
|
||||
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
|
||||
return nil, fmt.Errorf("scan role: %w", err)
|
||||
}
|
||||
out = append(out, ro)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
|
||||
var ro model.AdminRole
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT id, key, name, description, is_system, permissions, created_at
|
||||
FROM admin_roles WHERE id = $1`, id,
|
||||
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrRoleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load role: %w", err)
|
||||
}
|
||||
return &ro, nil
|
||||
}
|
||||
|
||||
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
|
||||
var ro model.AdminRole
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_roles (key, name, description, permissions)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, key, name, description, is_system, permissions, created_at`,
|
||||
key, name, description, perms,
|
||||
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "admin_roles_key_key") {
|
||||
return nil, ErrRoleKeyTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create role: %w", err)
|
||||
}
|
||||
return &ro, nil
|
||||
}
|
||||
|
||||
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
|
||||
// bug higher up still can't rewrite superadmin.
|
||||
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE admin_roles
|
||||
SET name = $2, description = $3, permissions = $4, updated_at = now()
|
||||
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update role: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrRoleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRole removes an unused CUSTOM role.
|
||||
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
|
||||
var members int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
|
||||
return fmt.Errorf("count role members: %w", err)
|
||||
}
|
||||
if members > 0 {
|
||||
return ErrRoleInUse
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete role: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrRoleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -39,7 +39,8 @@ type CategoryCount struct {
|
||||
// the table sizes are modest enough that chasing parallelism here
|
||||
// would buy sub-ms wins for added complexity.
|
||||
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
||||
s := &StatsSnapshot{}
|
||||
// Non-nil so an empty table serialises as [] rather than null.
|
||||
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
|
||||
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
scalars := []struct {
|
||||
|
||||
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
||||
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
|
||||
`SELECT COUNT(*) FROM users
|
||||
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
|
||||
substring).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count search users: %w", err)
|
||||
}
|
||||
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
||||
// and name could reshuffle between pages.
|
||||
sql := `SELECT ` + userColumns + `
|
||||
FROM users
|
||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
|
||||
-- Suspended accounts are unsearchable. This one query backs the
|
||||
-- people search page, the mention autocomplete AND both chat member
|
||||
-- pickers, so a banned account appearing here is offered as someone
|
||||
-- to @mention or add to a group. ResolveHandles already refuses them
|
||||
-- on the way in, which without this makes "Add" silently do nothing.
|
||||
AND banned_at IS NULL
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN handle::text ILIKE $2 THEN 0
|
||||
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
|
||||
FROM users u
|
||||
WHERE u.created_at >= $2
|
||||
AND u.id <> $1
|
||||
-- Suspended accounts are not discoverable. A ban hides someone from
|
||||
-- the app; surfacing them in the welcome strip would be the most
|
||||
-- prominent placement on the home page.
|
||||
AND u.banned_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||
// verses / prose_chapters / transcriptions collections).
|
||||
var (
|
||||
// ErrVaniRefInvalid is a malformed content id.
|
||||
ErrVaniRefInvalid = errors.New("not a valid content reference")
|
||||
// ErrVaniRefNotFound is a well-formed id that matches nothing.
|
||||
ErrVaniRefNotFound = errors.New("that content no longer exists")
|
||||
)
|
||||
|
||||
type VaniRepo struct {
|
||||
pg *Postgres
|
||||
mongo *MongoDB
|
||||
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert writes a day's pick, REPLACING any existing one.
|
||||
//
|
||||
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
|
||||
// picker must never overwrite a day already decided. An admin choosing the day
|
||||
// deliberately is the one case that should win.
|
||||
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
|
||||
b, err := json.Marshal(pick.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
ON CONFLICT (day) DO UPDATE
|
||||
SET kind = EXCLUDED.kind,
|
||||
ref_id = EXCLUDED.ref_id,
|
||||
payload = EXCLUDED.payload`,
|
||||
pick.Day, pick.Kind, pick.RefID, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert vani pick: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes a day's pick, so the automatic picker chooses again.
|
||||
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear vani pick: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PicksFrom lists decided days from `from` onward, for the admin schedule.
|
||||
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
|
||||
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vani picks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.VaniPick{}
|
||||
for rows.Next() {
|
||||
var p model.VaniPick
|
||||
var raw []byte
|
||||
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
|
||||
return nil, fmt.Errorf("scan vani pick: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p.Payload); err != nil {
|
||||
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||
@@ -135,6 +200,107 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
|
||||
return false, cur.Err()
|
||||
}
|
||||
|
||||
// oneByHex loads a single document by its _id hex. Shared by the three
|
||||
// admin-pick lookups, which differ only in collection and target type.
|
||||
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
|
||||
oid, err := bson.ObjectIDFromHex(hex)
|
||||
if err != nil {
|
||||
return ErrVaniRefInvalid
|
||||
}
|
||||
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return ErrVaniRefNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load %s %s: %w", coll, hex, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TranscriptBySlug loads a lecture by its URL slug.
|
||||
//
|
||||
// Search cannot do this: the slug is not an indexed field, so querying
|
||||
// "720720sb-london" matches on tokens like "london" and returns whatever else
|
||||
// mentions it. Resolving the slug directly is the only reliable way to pin a
|
||||
// specific item someone already has the URL for.
|
||||
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
|
||||
var t TranscriptionDoc
|
||||
err := r.mongo.Collection("transcriptions").
|
||||
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrVaniRefNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcript by slug: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ProseBySlug loads a prose chapter by book and slug.
|
||||
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
|
||||
var p ProseChapterDoc
|
||||
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
|
||||
{Key: "book", Value: book},
|
||||
{Key: "slug", Value: slug},
|
||||
}).Decode(&p)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrVaniRefNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prose by slug: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
|
||||
// which is what a verse URL carries.
|
||||
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
|
||||
filter := bson.D{
|
||||
{Key: "book", Value: book},
|
||||
{Key: "chapter", Value: chapter},
|
||||
{Key: "verse", Value: verse},
|
||||
}
|
||||
if division != nil && *division != "" {
|
||||
filter = append(filter, bson.E{Key: "division", Value: *division})
|
||||
}
|
||||
var v VerseDoc
|
||||
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrVaniRefNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verse by ref: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
||||
// chosen. The payload is then built by the same builders the random picker
|
||||
// uses, so a pinned pick renders identically to an automatic one.
|
||||
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
|
||||
var v VerseDoc
|
||||
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
|
||||
var p ProseChapterDoc
|
||||
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
|
||||
var t TranscriptionDoc
|
||||
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// SampleVerse returns a random verse with a translation, excluding the given
|
||||
// _id hexes. Returns nil when nothing matches.
|
||||
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||
|
||||
+230
-46
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
@@ -69,13 +70,19 @@ type Server struct {
|
||||
relationH *handler.RelationshipHandler
|
||||
soketiWebhookH *handler.SoketiWebhookHandler
|
||||
broadcastAuthH *handler.BroadcastingAuthHandler
|
||||
chatH *handler.ChatHandler
|
||||
notificationH *handler.NotificationHandler
|
||||
sadhnaH *handler.SadhnaHandler
|
||||
pushH *handler.PushHandler
|
||||
reminderH *handler.ReminderHandler
|
||||
feedbackH *handler.FeedbackHandler
|
||||
feedbackCatH *handler.FeedbackCategoryHandler
|
||||
statsH *handler.StatsHandler
|
||||
adminH *handler.AdminHandler
|
||||
broadcastH *handler.BroadcastHandler
|
||||
staffRepo *repository.StaffRepo
|
||||
staffH *handler.StaffHandler
|
||||
quizH *handler.QuizHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
authIssuer *auth.Issuer
|
||||
@@ -112,10 +119,10 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
||||
discipleRepo: discipleRepo,
|
||||
|
||||
// Initialize handlers
|
||||
bookH: handler.NewBookHandler(bookRepo),
|
||||
letterH: handler.NewLetterHandler(letterRepo),
|
||||
essayH: handler.NewEssayHandler(essayRepo),
|
||||
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
||||
bookH: handler.NewBookHandler(bookRepo),
|
||||
letterH: handler.NewLetterHandler(letterRepo),
|
||||
essayH: handler.NewEssayHandler(essayRepo),
|
||||
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
||||
// userRepo half is attached later in initAuth (Postgres isn't up yet).
|
||||
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
|
||||
journalH: handler.NewJournalHandler(journalRepo),
|
||||
@@ -193,9 +200,6 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
// across pages (nil-safe — falls back to offset paging without Redis).
|
||||
feedRepo := repository.NewFeedRepo(s.redis, logger)
|
||||
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
|
||||
// Soketi signs webhooks with the app secret; on channel_vacated for a
|
||||
// per-user notifications channel we evict that user's feed seen-set.
|
||||
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
|
||||
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
||||
|
||||
@@ -225,6 +229,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
||||
|
||||
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
|
||||
// target title and kind.
|
||||
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
|
||||
|
||||
// OAuth — wired only when at least one provider is configured. The
|
||||
// registry skips providers with missing creds, so partial configs (e.g.
|
||||
// Google only, no Facebook) work out of the box.
|
||||
@@ -258,17 +266,34 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
||||
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)
|
||||
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
|
||||
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
|
||||
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
|
||||
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
|
||||
|
||||
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
||||
chatRepo := repository.NewChatRepo(s.pg)
|
||||
chatSvc := service.NewChatService(chatRepo, blockRepo, 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
|
||||
// the author (push + realtime; not the liker themselves).
|
||||
postSvc.SetNotifier(notifSvc)
|
||||
@@ -296,7 +321,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
// every 15 minutes (no-op when Redis is unavailable).
|
||||
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.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
||||
@@ -312,15 +337,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
if err := rewardRepo.Seed(context.Background()); err != nil {
|
||||
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
||||
}
|
||||
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger)
|
||||
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, r2, logger)
|
||||
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
||||
|
||||
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
||||
// the existing mailer instance.
|
||||
feedbackRepo := repository.NewFeedbackRepo(s.pg)
|
||||
statsRepo := repository.NewStatsRepo(s.pg)
|
||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||
// Categories are admin-managed; submissions are checked against them.
|
||||
feedbackCatRepo := repository.NewFeedbackCategoryRepo(s.pg)
|
||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, feedbackCatRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||
s.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
|
||||
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
|
||||
|
||||
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||
@@ -328,7 +356,18 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
// Gated by RequireAuth + RequireAdmin in the router.
|
||||
adminRepo := repository.NewAdminRepo(s.pg)
|
||||
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||
// Admin RBAC: who is staff, with which permissions.
|
||||
s.staffRepo = repository.NewStaffRepo(s.pg)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
|
||||
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
|
||||
|
||||
// Broadcasts — admin announcements shown once per user. No notification
|
||||
// rows: the modal is the surface, and push plus the realtime nudge only
|
||||
// tell clients to go and check for one.
|
||||
broadcastSvc := service.NewBroadcastService(
|
||||
repository.NewBroadcastRepo(s.pg), pushSender, pushRepo,
|
||||
notifPrefsRepo, rtClient, r2, logger)
|
||||
s.broadcastH = handler.NewBroadcastHandler(broadcastSvc)
|
||||
}
|
||||
|
||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||
@@ -455,8 +494,14 @@ func (s *Server) setupRouter() {
|
||||
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||
// separate origin). Empty origins are filtered so the list never widens
|
||||
// unintentionally.
|
||||
adminURL := s.cfg.AdminURL
|
||||
if adminURL == "" && s.cfg.Env == "development" {
|
||||
// The admin app's dev server (`next dev -p 3001`), so local admin
|
||||
// work needs no extra config. Production must set ADMIN_URL.
|
||||
adminURL = "http://localhost:3001"
|
||||
}
|
||||
allowedOrigins := []string{}
|
||||
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
||||
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
|
||||
if o != "" {
|
||||
allowedOrigins = append(allowedOrigins, o)
|
||||
}
|
||||
@@ -684,6 +729,26 @@ func (s *Server) setupRouter() {
|
||||
// Vāṇī of the Day — public daily content pick for the home page.
|
||||
r.Get("/vani/today", s.vaniH.Today)
|
||||
|
||||
// Quizzes are for signed-in users: listing, finding one on a page,
|
||||
// taking it and reading results all need an account. The one open
|
||||
// endpoint is a quiz's own card — title, description, what it's
|
||||
// attached to, never its questions — so link previews (OpenGraph)
|
||||
// can name the quiz when it's shared.
|
||||
r.Route("/quizzes", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{id}", s.quizH.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/", s.quizH.List)
|
||||
r.Get("/for", s.quizH.ForPath)
|
||||
r.Post("/{id}/start", s.quizH.Start)
|
||||
r.Post("/{id}/answer", s.quizH.Answer)
|
||||
r.Get("/{id}/result", s.quizH.Result)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Auth ---
|
||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||
@@ -895,6 +960,63 @@ func (s *Server) setupRouter() {
|
||||
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
|
||||
// `authEndpoint`) before subscribing to a private-/presence- channel.
|
||||
// Session-authenticated: the signature is only minted for a caller
|
||||
@@ -994,6 +1116,8 @@ func (s *Server) setupRouter() {
|
||||
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// The form's categories — public, like the form itself.
|
||||
r.Get("/feedback/categories", s.feedbackCatH.Public)
|
||||
// Submit — OptionalAuth (anonymous callers allowed).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
@@ -1052,6 +1176,7 @@ func (s *Server) setupRouter() {
|
||||
r.Post("/chant", s.sadhnaH.SaveChant)
|
||||
r.Get("/rewards", s.sadhnaH.Rewards)
|
||||
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
||||
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
|
||||
if s.reminderH != nil {
|
||||
r.Get("/reminders", s.reminderH.GetPrefs)
|
||||
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
||||
@@ -1068,34 +1193,93 @@ func (s *Server) setupRouter() {
|
||||
return
|
||||
}
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||
r.Get("/stats", s.adminH.Stats)
|
||||
r.Get("/users", s.adminH.ListUsers)
|
||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
// Direct-disciple linking: mark an account as one of Śrīla
|
||||
// Prabhupāda's initiated disciples (or clear the mark).
|
||||
r.Get("/disciples", s.adminH.ListDisciples)
|
||||
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||
r.Get("/posts", s.adminH.ListPosts)
|
||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
||||
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||
r.Get("/collections", s.adminH.ListCollections)
|
||||
r.Get("/feedback", s.adminH.ListFeedback)
|
||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||
r.Get("/reports", s.adminH.ListReports)
|
||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
r.Get("/short-urls", s.adminH.ListShortURLs)
|
||||
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||
// Staff only past this point; each route then names the one
|
||||
// permission it needs. A route without a permission is reachable
|
||||
// by every staff member, so only /me is left bare.
|
||||
r.Use(middleware.RequireStaff(s.staffRepo))
|
||||
can := func(perm string) chi.Router {
|
||||
return r.With(middleware.RequirePermission(perm))
|
||||
}
|
||||
|
||||
r.Get("/me", s.staffH.Me)
|
||||
|
||||
// Staff and roles — superadmins only.
|
||||
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
|
||||
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
|
||||
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
|
||||
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
|
||||
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
|
||||
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
|
||||
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
|
||||
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
|
||||
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
|
||||
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
|
||||
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
|
||||
|
||||
can("stats:read").Get("/stats", s.adminH.Stats)
|
||||
|
||||
can("users:read").Get("/users", s.adminH.ListUsers)
|
||||
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
can("users:read").Get("/disciples", s.adminH.ListDisciples)
|
||||
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||
|
||||
can("vani:read").Get("/vani", s.vaniH.Schedule)
|
||||
can("vani:write").Post("/vani", s.vaniH.Set)
|
||||
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
|
||||
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
|
||||
|
||||
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
|
||||
|
||||
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
|
||||
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
|
||||
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
||||
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
|
||||
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
|
||||
|
||||
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
|
||||
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
|
||||
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||
can("collections:read").Get("/collections", s.adminH.ListCollections)
|
||||
|
||||
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
|
||||
can("feedback:read").Get("/feedback/categories", s.feedbackCatH.List)
|
||||
can("feedback:categories").Post("/feedback/categories", s.feedbackCatH.Create)
|
||||
can("feedback:categories").Put("/feedback/categories/order", s.feedbackCatH.Reorder)
|
||||
can("feedback:categories").Put("/feedback/categories/{key}", s.feedbackCatH.Update)
|
||||
can("feedback:categories").Delete("/feedback/categories/{key}", s.feedbackCatH.Delete)
|
||||
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||
|
||||
can("reports:read").Get("/reports", s.adminH.ListReports)
|
||||
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
|
||||
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
|
||||
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
|
||||
can("quizzes:write").Post("/quizzes", s.quizH.Create)
|
||||
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
|
||||
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
|
||||
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
|
||||
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
|
||||
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
|
||||
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
|
||||
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
|
||||
can("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
|
||||
|
||||
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
|
||||
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -335,8 +336,9 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f
|
||||
var buf bytes.Buffer
|
||||
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": name,
|
||||
"Reply": message,
|
||||
"URL": s.frontendURL,
|
||||
// Rendered markers; escaped by richtext, newlines become <br>.
|
||||
"Reply": richtext.ToHTML(message),
|
||||
"URL": s.frontendURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||
return true, false, nil
|
||||
@@ -435,7 +437,7 @@ var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<
|
||||
|
||||
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||
|
||||
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||
<blockquote style="border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,124 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCategoryLabel = 60
|
||||
maxCategoryDescription = 200
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCategoryLabel = fmt.Errorf("a category needs a name (up to %d characters)", maxCategoryLabel)
|
||||
ErrCategoryDescription = fmt.Errorf("the description can be up to %d characters", maxCategoryDescription)
|
||||
ErrCategoryLastActive = errors.New("the feedback form needs at least one category")
|
||||
)
|
||||
|
||||
var categoryNonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// FeedbackCategoryService manages the /feedback form's categories.
|
||||
type FeedbackCategoryService struct {
|
||||
repo *repository.FeedbackCategoryRepo
|
||||
}
|
||||
|
||||
func NewFeedbackCategoryService(repo *repository.FeedbackCategoryRepo) *FeedbackCategoryService {
|
||||
return &FeedbackCategoryService{repo: repo}
|
||||
}
|
||||
|
||||
// Active is what the /feedback form offers, in order.
|
||||
func (s *FeedbackCategoryService) Active(ctx context.Context) ([]model.FeedbackCategory, error) {
|
||||
return s.repo.List(ctx, false)
|
||||
}
|
||||
|
||||
// All is the admin's view: archived ones too, with usage counts.
|
||||
func (s *FeedbackCategoryService) All(ctx context.Context) ([]model.FeedbackCategory, error) {
|
||||
return s.repo.List(ctx, true)
|
||||
}
|
||||
|
||||
func cleanCategory(label, description string) (string, string, error) {
|
||||
label = strings.TrimSpace(label)
|
||||
description = strings.TrimSpace(description)
|
||||
if label == "" || len([]rune(label)) > maxCategoryLabel {
|
||||
return "", "", ErrCategoryLabel
|
||||
}
|
||||
if len([]rune(description)) > maxCategoryDescription {
|
||||
return "", "", ErrCategoryDescription
|
||||
}
|
||||
return label, description, nil
|
||||
}
|
||||
|
||||
// Create adds a category to the end of the form. Its key is derived from
|
||||
// the name ("Audio problem" → audio_problem) and fixed from then on, so it
|
||||
// can be renamed freely later without touching past feedback.
|
||||
func (s *FeedbackCategoryService) Create(ctx context.Context, label, description string) (*model.FeedbackCategory, error) {
|
||||
label, description, err := cleanCategory(label, description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := strings.Trim(categoryNonSlug.ReplaceAllString(strings.ToLower(label), "_"), "_")
|
||||
if base == "" {
|
||||
base = "category"
|
||||
}
|
||||
if len(base) > 40 {
|
||||
base = strings.TrimRight(base[:40], "_")
|
||||
}
|
||||
key := base
|
||||
for i := 2; ; i++ {
|
||||
taken, err := s.repo.KeyTaken(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !taken {
|
||||
break
|
||||
}
|
||||
key = fmt.Sprintf("%s_%d", base, i)
|
||||
}
|
||||
return s.repo.Create(ctx, key, label, description)
|
||||
}
|
||||
|
||||
// Update renames, rewords, archives or restores a category. The last active
|
||||
// category can't be archived — the form would have nothing to offer.
|
||||
func (s *FeedbackCategoryService) Update(ctx context.Context, key, label, description string, archived bool) error {
|
||||
label, description, err := cleanCategory(label, description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if archived {
|
||||
if err := s.guardLastActive(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.repo.Update(ctx, key, label, description, archived)
|
||||
}
|
||||
|
||||
// Delete removes a category nothing is filed under (otherwise: archive it).
|
||||
func (s *FeedbackCategoryService) Delete(ctx context.Context, key string) error {
|
||||
if err := s.guardLastActive(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// Reorder sets the form's order.
|
||||
func (s *FeedbackCategoryService) Reorder(ctx context.Context, keys []string) error {
|
||||
return s.repo.Reorder(ctx, keys)
|
||||
}
|
||||
|
||||
func (s *FeedbackCategoryService) guardLastActive(ctx context.Context, key string) error {
|
||||
active, err := s.repo.List(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(active) == 1 && active[0].Key == key {
|
||||
return ErrCategoryLastActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Categories are managed from the admin: a new one is offered on the form
|
||||
// and accepts feedback; renaming relabels past feedback; archiving stops new
|
||||
// submissions but keeps the label; in-use categories can't be deleted.
|
||||
func TestFeedbackCategories(t *testing.T) {
|
||||
_, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
repo := repository.NewFeedbackCategoryRepo(pg)
|
||||
cats := NewFeedbackCategoryService(repo)
|
||||
fb := NewFeedbackService(repository.NewFeedbackRepo(pg), repo, nil, nil, zap.NewNop())
|
||||
user := chatUser(t, pg, "Feedback")
|
||||
t.Cleanup(func() {
|
||||
bg := context.Background()
|
||||
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback WHERE user_id = $1`, user)
|
||||
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback_categories WHERE key LIKE 'zz_qa_audio%'`)
|
||||
})
|
||||
|
||||
// The seeded categories production already uses are all on the form.
|
||||
active, err := cats.Active(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range active {
|
||||
seen[c.Key] = true
|
||||
}
|
||||
for _, k := range []string{"general", "bug", "feature", "other"} {
|
||||
if !seen[k] {
|
||||
t.Errorf("seeded category %q missing from the form", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Keys come from the name, and never collide.
|
||||
c1, err := cats.Create(ctx, "ZZ QA Audio problem", "Playback trouble")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2, err := cats.Create(ctx, "ZZ QA audio problem!", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c1.Key != "zz_qa_audio_problem" || c2.Key != "zz_qa_audio_problem_2" {
|
||||
t.Errorf("keys = %q, %q", c1.Key, c2.Key)
|
||||
}
|
||||
if _, err := cats.Create(ctx, " ", ""); !errors.Is(err, ErrCategoryLabel) {
|
||||
t.Errorf("blank name: %v, want ErrCategoryLabel", err)
|
||||
}
|
||||
|
||||
// New categories take feedback; unknown ones don't.
|
||||
if _, err := fb.Submit(ctx, &user, c1.Key, "the audio stutters", nil); err != nil {
|
||||
t.Fatalf("submit in a new category: %v", err)
|
||||
}
|
||||
if _, err := fb.Submit(ctx, &user, "no_such_category", "x", nil); !errors.Is(err, ErrFeedbackCategory) {
|
||||
t.Errorf("unknown category: %v, want ErrFeedbackCategory", err)
|
||||
}
|
||||
|
||||
// Renaming relabels what's already been filed.
|
||||
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "Playback trouble", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine, _ := fb.ListForUser(ctx, user, 10)
|
||||
if len(mine) != 1 || mine[0].CategoryLabel != "ZZ QA Audio & playback" || mine[0].Category != c1.Key {
|
||||
t.Errorf("past feedback after rename = %+v", mine)
|
||||
}
|
||||
|
||||
// Archived: off the form, refuses new feedback, keeps its label.
|
||||
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fb.Submit(ctx, &user, c1.Key, "again", nil); !errors.Is(err, ErrFeedbackCategory) {
|
||||
t.Errorf("submit to an archived category: %v, want ErrFeedbackCategory", err)
|
||||
}
|
||||
active, _ = cats.Active(ctx)
|
||||
for _, c := range active {
|
||||
if c.Key == c1.Key {
|
||||
t.Error("archived category still on the form")
|
||||
}
|
||||
}
|
||||
if mine, _ := fb.ListForUser(ctx, user, 10); mine[0].CategoryLabel != "ZZ QA Audio & playback" {
|
||||
t.Error("archiving lost the label on past feedback")
|
||||
}
|
||||
all, _ := cats.All(ctx)
|
||||
for _, c := range all {
|
||||
if c.Key == c1.Key && (c.ArchivedAt == nil || c.UsageCount != 1) {
|
||||
t.Errorf("admin view of archived category = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// In use → can't be deleted; unused → can.
|
||||
if err := cats.Delete(ctx, c1.Key); !errors.Is(err, repository.ErrFeedbackCategoryInUse) {
|
||||
t.Errorf("deleting an in-use category: %v, want ErrFeedbackCategoryInUse", err)
|
||||
}
|
||||
if err := cats.Delete(ctx, c2.Key); err != nil {
|
||||
t.Errorf("deleting an unused category: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,24 +21,23 @@ import (
|
||||
)
|
||||
|
||||
type FeedbackService struct {
|
||||
repo *repository.FeedbackRepo
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
repo *repository.FeedbackRepo
|
||||
// categories is the admin-managed list a submission must belong to.
|
||||
categories *repository.FeedbackCategoryRepo
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
||||
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
|
||||
func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
||||
return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger}
|
||||
}
|
||||
|
||||
// Allowed categories — kept lowercase in storage so dashboards group
|
||||
// cleanly. The frontend dropdown sends one of these exact strings.
|
||||
var feedbackCategories = map[string]bool{
|
||||
"bug": true,
|
||||
"feature": true,
|
||||
"general": true,
|
||||
"other": true,
|
||||
}
|
||||
// ErrFeedbackCategory: the submission names a category that doesn't exist
|
||||
// or has been archived in the admin panel.
|
||||
var ErrFeedbackCategory = errors.New("invalid category")
|
||||
|
||||
// Submit persists a feedback row and fires the admin email.
|
||||
//
|
||||
@@ -49,8 +48,10 @@ var feedbackCategories = map[string]bool{
|
||||
// trustworthy (the form can't lie about who's writing).
|
||||
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !feedbackCategories[category] {
|
||||
return nil, errors.New("invalid category")
|
||||
if ok, err := s.categories.IsActive(ctx, category); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrFeedbackCategory
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
@@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Hydrate the joined author fields for the email template. Failing
|
||||
// the lookup isn't worth failing the request — we fall back to
|
||||
// "anonymous" rather than 500ing on the user.
|
||||
if userID != nil {
|
||||
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
||||
f = full
|
||||
} else {
|
||||
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
||||
zap.String("feedback_id", f.ID), zap.Error(err))
|
||||
}
|
||||
// Hydrate the joined author fields and category label for the email
|
||||
// template. Failing the lookup isn't worth failing the request — we fall
|
||||
// back to "anonymous" and the category key rather than 500ing.
|
||||
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
||||
f = full
|
||||
} else {
|
||||
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
||||
zap.String("feedback_id", f.ID), zap.Error(err))
|
||||
f.CategoryLabel = f.Category
|
||||
}
|
||||
|
||||
s.notifyAdmins(f)
|
||||
@@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
|
||||
s.logger.Warn("render feedback email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
|
||||
subject := fmt.Sprintf("[Feedback · %s] %s", categoryLabel(f), summarize(f.Message, 60))
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("feedback email send failed",
|
||||
@@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := feedbackEmailTpl.Execute(&buf, map[string]any{
|
||||
"Category": f.Category,
|
||||
"Category": categoryLabel(f),
|
||||
"Message": f.Message,
|
||||
"From": from,
|
||||
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
|
||||
@@ -360,3 +360,11 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// categoryLabel is the category's display name, or its key if unknown.
|
||||
func categoryLabel(f *model.Feedback) string {
|
||||
if f.CategoryLabel != "" {
|
||||
return f.CategoryLabel
|
||||
}
|
||||
return f.Category
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) {
|
||||
t.Helper()
|
||||
uri := os.Getenv("POSTGRES_URI")
|
||||
if uri == "" {
|
||||
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
|
||||
uri = os.Getenv("POSTGRES_URI")
|
||||
}
|
||||
if uri == "" {
|
||||
t.Skip("POSTGRES_URI not set")
|
||||
}
|
||||
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Skipf("postgres unreachable: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
return repository.NewRewardRepo(pg), pg
|
||||
}
|
||||
|
||||
func giftUser(t *testing.T, pg *repository.Postgres) string {
|
||||
t.Helper()
|
||||
id := uuid.NewString()
|
||||
suffix := id[:8]
|
||||
if _, err := pg.Pool.Exec(context.Background(), `
|
||||
INSERT INTO users (id, email, password_hash, name, handle)
|
||||
VALUES ($1, $2, 'x', 'Gift User', $3)`,
|
||||
id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func giftReward(t *testing.T, pg *repository.Postgres) string {
|
||||
t.Helper()
|
||||
key := "gift_" + uuid.NewString()[:8]
|
||||
if _, err := pg.Pool.Exec(context.Background(), `
|
||||
INSERT INTO sadhna_rewards (key, name, image_url)
|
||||
VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key)
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting
|
||||
// must not duplicate it.
|
||||
func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) {
|
||||
rewards, pg := testRewardRepo(t)
|
||||
ctx := context.Background()
|
||||
user := giftUser(t, pg)
|
||||
key := giftReward(t, pg)
|
||||
|
||||
reward, err := rewards.ByKey(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
find := func() (found bool, opened bool) {
|
||||
grants, err := rewards.GrantsByUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range grants {
|
||||
if g.Reward.Key == key {
|
||||
if g.Source != "gift" {
|
||||
t.Errorf("source = %q, want gift", g.Source)
|
||||
}
|
||||
if g.Milestone != nil {
|
||||
t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone)
|
||||
}
|
||||
return true, g.Opened
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
found, opened := find()
|
||||
if !found {
|
||||
t.Fatal("the gift did not reach an existing account")
|
||||
}
|
||||
if opened {
|
||||
t.Error("a new gift should arrive wrapped")
|
||||
}
|
||||
|
||||
// It must NOT hijack the streak confetti — that stays for streak rewards.
|
||||
if pending, _ := rewards.PendingGrant(ctx, user); pending != nil {
|
||||
t.Errorf("a gift appeared as a pending streak celebration: %+v", pending)
|
||||
}
|
||||
|
||||
if err := rewards.OpenGift(ctx, user, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, opened = find(); !opened {
|
||||
t.Error("opening the gift did not un-blur it")
|
||||
}
|
||||
// Opening twice is harmless.
|
||||
if err := rewards.OpenGift(ctx, user, key); err != nil {
|
||||
t.Errorf("second open should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
// Re-gifting tops up newcomers without duplicating anyone's tile.
|
||||
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, _ := rewards.GrantsByUser(ctx, user)
|
||||
n := 0
|
||||
for _, g := range grants {
|
||||
if g.Reward.Key == key {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("holdings of the gift = %d, want 1", n)
|
||||
}
|
||||
// ...and it must not silently re-wrap what was already opened.
|
||||
if _, opened = find(); !opened {
|
||||
t.Error("re-gifting re-wrapped an already opened gift")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a custom gift must clear it from every gallery it reached, and must
|
||||
// refuse to touch the seeded catalog — a seeded reward would return on the
|
||||
// next Seed run, after having been ripped out of people's galleries.
|
||||
func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
|
||||
rewards, pg := testRewardRepo(t)
|
||||
ctx := context.Background()
|
||||
user := giftUser(t, pg)
|
||||
|
||||
// A custom reward, as the admin panel creates them.
|
||||
key := "custom_probe_" + uuid.NewString()[:8]
|
||||
if _, err := pg.Pool.Exec(ctx, `
|
||||
INSERT INTO sadhna_rewards (key, name, image_url)
|
||||
VALUES ($1, 'Probe Gift', 'https://example.invalid/p.png')`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reward, err := rewards.ByKey(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
held := func() int {
|
||||
grants, err := rewards.GrantsByUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, g := range grants {
|
||||
if g.Reward.Key == key {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
if held() != 1 {
|
||||
t.Fatalf("setup: the gift did not reach the gallery")
|
||||
}
|
||||
|
||||
if err := rewards.DeleteCustom(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if held() != 0 {
|
||||
t.Error("deleting the reward left it in a devotee's gallery")
|
||||
}
|
||||
if _, err := rewards.ByKey(ctx, key); !errors.Is(err, repository.ErrRewardNotFound) {
|
||||
t.Errorf("reward still in the catalog: %v", err)
|
||||
}
|
||||
|
||||
// Seeded rewards are off limits.
|
||||
var seeded string
|
||||
if err := pg.Pool.QueryRow(ctx,
|
||||
`SELECT key FROM sadhna_rewards WHERE key NOT LIKE 'custom\_%' LIMIT 1`,
|
||||
).Scan(&seeded); err != nil {
|
||||
t.Skip("no seeded rewards to test against")
|
||||
}
|
||||
if err := rewards.DeleteCustom(ctx, seeded); !errors.Is(err, repository.ErrRewardNotFound) {
|
||||
t.Errorf("a seeded reward was deletable: %v", err)
|
||||
}
|
||||
if _, err := rewards.ByKey(ctx, seeded); err != nil {
|
||||
t.Errorf("the seeded reward was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A suspended account must not be discoverable. One query backs the people
|
||||
// search page, the mention autocomplete and the chat member pickers, so a leak
|
||||
// here surfaces in all three.
|
||||
func TestBannedUsers_AreNotDiscoverable(t *testing.T) {
|
||||
_, pg := testRewardRepo(t)
|
||||
ctx := context.Background()
|
||||
users := repository.NewUserRepo(pg)
|
||||
|
||||
// A distinctive name so the search can't match anyone else.
|
||||
marker := "Zzbanprobe" + uuid.NewString()[:6]
|
||||
active := giftUser(t, pg)
|
||||
banned := giftUser(t, pg)
|
||||
for id, name := range map[string]string{active: marker + " Active", banned: marker + " Banned"} {
|
||||
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET name = $2 WHERE id = $1`, id, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET banned_at = now() WHERE id = $1`, banned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, total, err := users.Search(ctx, marker, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, u := range found {
|
||||
ids[u.ID] = true
|
||||
}
|
||||
if !ids[active] {
|
||||
t.Error("the active account is missing from search")
|
||||
}
|
||||
if ids[banned] {
|
||||
t.Error("a suspended account is searchable — it can be @mentioned or added to a group")
|
||||
}
|
||||
// The count backs pagination, so it has to agree with the page.
|
||||
if total != len(found) {
|
||||
t.Errorf("total = %d but page returned %d — count and page WHERE disagree", total, len(found))
|
||||
}
|
||||
|
||||
// And the welcome strip, which is the most prominent placement there is.
|
||||
recent, err := users.RecentlyJoined(ctx, active, time.Now().Add(-24*time.Hour), 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range recent {
|
||||
if u.ID == banned {
|
||||
t.Error("a suspended account appears in the new-users strip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,11 @@ const (
|
||||
// disciples. The actor is the disciple, so it renders and links exactly
|
||||
// like the other actor-driven kinds.
|
||||
NotifDiscipleJoined = "disciple_joined"
|
||||
// NotifChatMessage exists ONLY as a preference key. Chat never creates a
|
||||
// notifications row and never appears in the tray — a new message
|
||||
// highlights its inbox row instead. The kind is here so the settings
|
||||
// matrix can offer a push opt-out alongside everything else.
|
||||
NotifChatMessage = "chat_message"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
@@ -60,6 +66,9 @@ var PrefCategories = map[string][]string{
|
||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||
"community": {NotifDiscipleJoined},
|
||||
// Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
|
||||
// In-app delivery for chat is the inbox itself, so it isn't offered here.
|
||||
"messages": {NotifChatMessage},
|
||||
}
|
||||
|
||||
// PrefChannels is every delivery channel a matrix cell can address.
|
||||
@@ -292,7 +301,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
||||
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
||||
s.push.SendToUser(ctx, authorID, PushPayload{
|
||||
Title: "New Like",
|
||||
Body: body,
|
||||
Body: richtext.Plain(body),
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifPostLike + ":" + postID,
|
||||
@@ -634,7 +643,7 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
||||
if s.rt != nil {
|
||||
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
||||
"kind": "admin_message",
|
||||
"body": body,
|
||||
"body": richtext.Plain(body),
|
||||
}); err != nil {
|
||||
s.logger.Warn("realtime admin message publish failed",
|
||||
zap.String("recipient", recipientID), zap.Error(err))
|
||||
@@ -662,8 +671,10 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
||||
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"Title": strings.TrimSpace(title),
|
||||
"Body": body,
|
||||
"URL": url,
|
||||
// Rendered markers (bold, links…). Already escaped by richtext,
|
||||
// and its <br>s replace the old white-space:pre-line.
|
||||
"Body": richtext.ToHTML(body),
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||
} else {
|
||||
@@ -874,7 +885,7 @@ var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<di
|
||||
|
||||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||
|
||||
<p style="white-space:pre-line">{{.Body}}</p>
|
||||
<p>{{.Body}}</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQuizQuestions = 100
|
||||
minQuizOptions = 2
|
||||
maxQuizOptions = 5
|
||||
maxQuizTitle = 200
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQuizTitle = errors.New("a quiz needs a title")
|
||||
ErrQuizNoQuestions = errors.New("a quiz needs at least one question")
|
||||
ErrQuizTooMany = fmt.Errorf("a quiz can have at most %d questions", maxQuizQuestions)
|
||||
ErrQuizPrompt = errors.New("every question needs a prompt")
|
||||
ErrQuizOptionCount = fmt.Errorf("each question needs %d to %d options", minQuizOptions, maxQuizOptions)
|
||||
ErrQuizOptionBody = errors.New("every option needs text")
|
||||
ErrQuizOneCorrect = errors.New("a single-answer question needs exactly one correct option")
|
||||
ErrQuizNoCorrect = errors.New("a multiple-answer question needs at least one correct option")
|
||||
ErrQuizLocked = errors.New("people have already taken this quiz, so its questions can't change")
|
||||
ErrQuizTarget = errors.New("that link isn't a book chapter, lecture or conversation")
|
||||
ErrQuizUnpublished = errors.New("this quiz isn't available")
|
||||
)
|
||||
|
||||
// QuizService is both sides of quizzes: authoring for staff, taking for users.
|
||||
type QuizService struct {
|
||||
quizzes *repository.QuizRepo
|
||||
// content resolves a pasted link into a target title and kind.
|
||||
content *repository.VaniRepo
|
||||
}
|
||||
|
||||
func NewQuizService(quizzes *repository.QuizRepo, content *repository.VaniRepo) *QuizService {
|
||||
return &QuizService{quizzes: quizzes, content: content}
|
||||
}
|
||||
|
||||
// QuizDraft is what the admin editor submits. TargetURL is pasted from the
|
||||
// app; empty means a stand-alone quiz.
|
||||
type QuizDraft struct {
|
||||
Title string
|
||||
Description string
|
||||
TargetURL string
|
||||
// TargetTitle overrides the resolved content title when set.
|
||||
TargetTitle string
|
||||
Questions []repository.QuestionInput
|
||||
}
|
||||
|
||||
// NormalizeContentPath reduces a pasted link to the path a content page
|
||||
// reports for itself: no scheme, host, query, fragment or trailing slash.
|
||||
// Both sides normalise identically, so "https://…/books/bg/2/?x" and the
|
||||
// page's own "/books/bg/2" meet.
|
||||
func NormalizeContentPath(raw string) string {
|
||||
p := strings.TrimSpace(raw)
|
||||
if u, err := url.Parse(p); err == nil && u.Host != "" {
|
||||
p = u.Path
|
||||
}
|
||||
p = strings.SplitN(strings.SplitN(p, "?", 2)[0], "#", 2)[0]
|
||||
p = "/" + strings.Trim(p, "/")
|
||||
if p == "/" {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// resolveTarget works out what a link points at.
|
||||
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, year *int, err error) {
|
||||
p := NormalizeContentPath(raw)
|
||||
if p == "" {
|
||||
return model.QuizStandalone, nil, "", nil, nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||
|
||||
switch {
|
||||
case len(parts) == 2 && parts[0] == "transcriptions":
|
||||
t, err := s.content.TranscriptBySlug(ctx, parts[1])
|
||||
if err != nil {
|
||||
return "", nil, "", nil, ErrQuizTarget
|
||||
}
|
||||
kind = model.QuizLecture
|
||||
if t.Type == "conversation" {
|
||||
kind = model.QuizConversation
|
||||
}
|
||||
title = t.Name
|
||||
year = t.Year
|
||||
|
||||
case len(parts) >= 3 && parts[0] == "books":
|
||||
kind = model.QuizChapter
|
||||
book := parts[1]
|
||||
bookTitle := s.content.BookTitle(ctx, book)
|
||||
if bookTitle == "" {
|
||||
bookTitle = strings.ToUpper(book)
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
if _, numErr := strconv.Atoi(last); numErr == nil {
|
||||
// /books/bg/2, /books/sb/1/2, /books/cc/adi/3 — a numbered chapter.
|
||||
title = bookTitle + " — Chapter " + last
|
||||
} else if pr, err := s.content.ProseBySlug(ctx, book, last); err == nil {
|
||||
title = pr.Title
|
||||
} else {
|
||||
title = bookTitle
|
||||
}
|
||||
|
||||
default:
|
||||
return "", nil, "", nil, ErrQuizTarget
|
||||
}
|
||||
|
||||
if o := strings.TrimSpace(override); o != "" {
|
||||
title = o
|
||||
}
|
||||
return kind, &p, title, year, nil
|
||||
}
|
||||
|
||||
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
|
||||
if len(qs) == 0 {
|
||||
return nil, ErrQuizNoQuestions
|
||||
}
|
||||
if len(qs) > maxQuizQuestions {
|
||||
return nil, ErrQuizTooMany
|
||||
}
|
||||
out := make([]repository.QuestionInput, 0, len(qs))
|
||||
for _, q := range qs {
|
||||
q.Prompt = strings.TrimSpace(q.Prompt)
|
||||
q.Explanation = strings.TrimSpace(q.Explanation)
|
||||
if q.Prompt == "" {
|
||||
return nil, ErrQuizPrompt
|
||||
}
|
||||
if len(q.Options) < minQuizOptions || len(q.Options) > maxQuizOptions {
|
||||
return nil, ErrQuizOptionCount
|
||||
}
|
||||
correct := 0
|
||||
for i := range q.Options {
|
||||
q.Options[i].Body = strings.TrimSpace(q.Options[i].Body)
|
||||
if q.Options[i].Body == "" {
|
||||
return nil, ErrQuizOptionBody
|
||||
}
|
||||
if q.Options[i].IsCorrect {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
if q.Multi && correct == 0 {
|
||||
return nil, ErrQuizNoCorrect
|
||||
}
|
||||
if !q.Multi && correct != 1 {
|
||||
return nil, ErrQuizOneCorrect
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) toInput(ctx context.Context, d QuizDraft, withQuestions bool) (repository.QuizInput, error) {
|
||||
in := repository.QuizInput{
|
||||
Title: strings.TrimSpace(d.Title),
|
||||
Description: strings.TrimSpace(d.Description),
|
||||
}
|
||||
if in.Title == "" || len([]rune(in.Title)) > maxQuizTitle {
|
||||
return in, ErrQuizTitle
|
||||
}
|
||||
var err error
|
||||
if in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
|
||||
return in, err
|
||||
}
|
||||
if withQuestions {
|
||||
if in.Questions, err = validateQuestions(d.Questions); err != nil {
|
||||
return in, err
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *QuizService) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
|
||||
return s.quizzes.ListAdmin(ctx)
|
||||
}
|
||||
|
||||
func (s *QuizService) GetAdmin(ctx context.Context, id string) (*model.Quiz, error) {
|
||||
q, err := s.quizzes.Get(ctx, id, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.AttemptCount, err = s.quizzes.AttemptCount(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (*model.Quiz, error) {
|
||||
in, err := s.toInput(ctx, d, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureTargetFree(ctx, in, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.quizzes.Create(ctx, in, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
// Update rewrites a quiz. Once anyone has attempted it, its questions are
|
||||
// frozen — their answers reference those exact questions and options — and
|
||||
// only the title, description and target may change. Sending questions then
|
||||
// is refused rather than silently ignored.
|
||||
func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*model.Quiz, error) {
|
||||
attempts, err := s.quizzes.AttemptCount(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locked := attempts > 0
|
||||
if locked && len(d.Questions) > 0 {
|
||||
return nil, ErrQuizLocked
|
||||
}
|
||||
in, err := s.toInput(ctx, d, !locked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureTargetFree(ctx, in, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
// ensureTargetFree refuses a second quiz on the same chapter, lecture or
|
||||
// conversation, naming the one already there so the admin can edit it
|
||||
// instead. Stand-alone quizzes are never limited. The unique index backs
|
||||
// this up against a race between two saves.
|
||||
func (s *QuizService) ensureTargetFree(ctx context.Context, in repository.QuizInput, selfID string) error {
|
||||
if in.TargetPath == nil {
|
||||
return nil
|
||||
}
|
||||
_, title, err := s.quizzes.QuizOnTarget(ctx, *in.TargetPath, selfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if title != "" {
|
||||
return fmt.Errorf("%w — %q is already linked to %s. Edit that quiz, or delete it first",
|
||||
repository.ErrQuizTargetTaken, title, *in.TargetPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
|
||||
if published {
|
||||
q, err := s.quizzes.Get(ctx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(q.Questions) == 0 {
|
||||
return nil, ErrQuizNoQuestions
|
||||
}
|
||||
}
|
||||
if err := s.quizzes.SetPublished(ctx, id, published); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Delete(ctx context.Context, id string) error {
|
||||
return s.quizzes.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Participants(ctx context.Context, id string) ([]model.QuizParticipant, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.Participants(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Responses(ctx context.Context, id string) ([]model.QuizQuestionStat, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.QuestionStats(ctx, id)
|
||||
}
|
||||
|
||||
// ParticipantResult is one person's answers, for the admin — including an
|
||||
// attempt still in progress, which a user can never see of their own.
|
||||
func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID string) (*model.QuizResult, error) {
|
||||
return s.quizzes.Result(ctx, quizID, userID, true)
|
||||
}
|
||||
|
||||
// ResetParticipant wipes one participant's attempt; the quiz becomes pending
|
||||
// for them. Works on unpublished quizzes too — staff may tidy results up
|
||||
// while a quiz is offline.
|
||||
func (s *QuizService) ResetParticipant(ctx context.Context, quizID, userID string) error {
|
||||
if _, err := s.quizzes.Get(ctx, quizID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.quizzes.ResetAttempt(ctx, quizID, userID)
|
||||
}
|
||||
|
||||
// ── Taking ────────────────────────────────────────────────────────────────
|
||||
|
||||
// published loads a quiz that users may see.
|
||||
func (s *QuizService) published(ctx context.Context, id string) (*model.Quiz, error) {
|
||||
q, err := s.quizzes.Get(ctx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.PublishedAt == nil {
|
||||
return nil, ErrQuizUnpublished
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) withStatus(ctx context.Context, userID string, qs []model.Quiz) ([]model.QuizCard, error) {
|
||||
ids := make([]string, len(qs))
|
||||
for i, q := range qs {
|
||||
ids[i] = q.ID
|
||||
}
|
||||
st, err := s.quizzes.Statuses(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.QuizCard, len(qs))
|
||||
for i, q := range qs {
|
||||
q.Questions = nil
|
||||
my, ok := st[q.ID]
|
||||
if !ok {
|
||||
my = model.QuizStatus{State: model.QuizNotStarted, Total: q.QuestionCount}
|
||||
}
|
||||
out[i] = model.QuizCard{Quiz: q, My: my}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForPath returns the quiz on a content page with the caller's progress, or
|
||||
// nil when the page has none. userID may be empty for signed-out readers.
|
||||
func (s *QuizService) ForPath(ctx context.Context, userID, rawPath string) (*model.QuizCard, error) {
|
||||
p := NormalizeContentPath(rawPath)
|
||||
if p == "" {
|
||||
return nil, nil
|
||||
}
|
||||
q, err := s.quizzes.PublishedForPath(ctx, p)
|
||||
if err != nil || q == nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cards[0], nil
|
||||
}
|
||||
|
||||
func (s *QuizService) ListPublished(ctx context.Context, userID string) ([]model.QuizCard, error) {
|
||||
qs, err := s.quizzes.ListPublished(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.withStatus(ctx, userID, qs)
|
||||
}
|
||||
|
||||
func (s *QuizService) Card(ctx context.Context, userID, id string) (*model.QuizCard, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cards[0], nil
|
||||
}
|
||||
|
||||
// Start opens (or resumes) the caller's attempt and returns where they are.
|
||||
func (s *QuizService) Start(ctx context.Context, userID, id string) (*model.QuizPlay, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Start(ctx, id, userID, len(q.Questions)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next(ctx, userID, q)
|
||||
}
|
||||
|
||||
func (s *QuizService) next(ctx context.Context, userID string, q *model.Quiz) (*model.QuizPlay, error) {
|
||||
play, err := s.quizzes.Next(ctx, q.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
play.Title = q.Title
|
||||
return play, nil
|
||||
}
|
||||
|
||||
// Answer records a choice — one option, or several on a multi question — and
|
||||
// returns the next step.
|
||||
func (s *QuizService) Answer(ctx context.Context, userID, id, questionID string, optionIDs []string) (*model.QuizPlay, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Answer(ctx, id, userID, questionID, optionIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next(ctx, userID, q)
|
||||
}
|
||||
|
||||
// Result is the caller's OWN finished attempt — the only result a user can
|
||||
// read. The user id comes from the session, never the request.
|
||||
func (s *QuizService) Result(ctx context.Context, userID, id string) (*model.QuizResult, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.Result(ctx, id, userID, false)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
func q(prompt string, correct int, opts ...string) repository.QuestionInput {
|
||||
out := repository.QuestionInput{Prompt: prompt}
|
||||
for i, o := range opts {
|
||||
out.Options = append(out.Options, repository.OptionInput{Body: o, IsCorrect: i == correct})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withExplanation(x repository.QuestionInput, e string) repository.QuestionInput {
|
||||
x.Explanation = e
|
||||
return x
|
||||
}
|
||||
|
||||
func newQuizSvc(t *testing.T) (*QuizService, *repository.Postgres) {
|
||||
t.Helper()
|
||||
_, _, pg := testChatService(t)
|
||||
// Stand-alone quizzes never consult content, so no Mongo is needed.
|
||||
return NewQuizService(repository.NewQuizRepo(pg), nil), pg
|
||||
}
|
||||
|
||||
func cleanupQuiz(t *testing.T, pg *repository.Postgres, id string) {
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM quizzes WHERE id = $1`, id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuiz_Validation(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin := chatUser(t, pg, "Admin")
|
||||
|
||||
cases := map[string]struct {
|
||||
d QuizDraft
|
||||
want error
|
||||
}{
|
||||
"no title": {QuizDraft{Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTitle},
|
||||
"no questions": {QuizDraft{Title: "T"}, ErrQuizNoQuestions},
|
||||
"one option": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a")}}, ErrQuizOptionCount},
|
||||
"six options": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a", "b", "c", "d", "e", "f")}}, ErrQuizOptionCount},
|
||||
"no correct": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", -1, "a", "b")}}, ErrQuizOneCorrect},
|
||||
"blank prompt": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q(" ", 0, "a", "b")}}, ErrQuizPrompt},
|
||||
"bad target url": {QuizDraft{Title: "T", TargetURL: "/letters/x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTarget},
|
||||
}
|
||||
for name, c := range cases {
|
||||
if _, err := svc.Create(ctx, admin, c.d); !errors.Is(err, c.want) {
|
||||
t.Errorf("%s: err = %v, want %v", name, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
two := q("p", 1, "a", "b")
|
||||
two.Options[0].IsCorrect = true // two correct
|
||||
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); !errors.Is(err, ErrQuizOneCorrect) {
|
||||
t.Errorf("two correct: %v", err)
|
||||
}
|
||||
// …which is exactly what a multi question allows, but it needs one.
|
||||
two.Multi = true
|
||||
if quiz, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); err != nil {
|
||||
t.Errorf("multi with two correct: %v", err)
|
||||
} else {
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
}
|
||||
none := q("p", -1, "a", "b")
|
||||
none.Multi = true
|
||||
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{none}}); !errors.Is(err, ErrQuizNoCorrect) {
|
||||
t.Errorf("multi with none correct: %v, want ErrQuizNoCorrect", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi question is scored correct only on the exact set of correct
|
||||
// options: a subset or an extra wrong pick both miss.
|
||||
func TestQuiz_MultiChoice(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin := chatUser(t, pg, "Admin")
|
||||
|
||||
multi := func(prompt string) repository.QuestionInput {
|
||||
x := q(prompt, 0, "right A", "right B", "wrong")
|
||||
x.Options[1].IsCorrect = true
|
||||
x.Multi = true
|
||||
return x
|
||||
}
|
||||
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||
Title: "Select all",
|
||||
Questions: []repository.QuestionInput{multi("exact"), multi("subset"), multi("extra"), q("single", 0, "a", "b")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||
if !full.Questions[0].Multi || full.Questions[3].Multi {
|
||||
t.Fatal("multi flag didn't round-trip")
|
||||
}
|
||||
opt := func(qi, oi int) string { return full.Questions[qi].Options[oi].ID }
|
||||
|
||||
user := chatUser(t, pg, "U")
|
||||
play, err := svc.Start(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !play.Question.Multi {
|
||||
t.Error("the player isn't told the question takes several answers")
|
||||
}
|
||||
// Nothing chosen is refused.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||
t.Errorf("empty answer: %v, want ErrQuizChoiceCount", err)
|
||||
}
|
||||
steps := [][]string{
|
||||
{opt(0, 1), opt(0, 0), opt(0, 0)}, // exact set, any order, duplicate ignored
|
||||
{opt(1, 0)}, // subset
|
||||
{opt(2, 0), opt(2, 1), opt(2, 2)}, // every option, one wrong
|
||||
}
|
||||
for _, ids := range steps {
|
||||
if play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, ids); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A single-answer question still takes exactly one.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0), opt(3, 1)}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||
t.Errorf("two picks on a single question: %v, want ErrQuizChoiceCount", err)
|
||||
}
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := svc.Result(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []bool{}
|
||||
for _, it := range res.Items {
|
||||
got = append(got, it.IsCorrect)
|
||||
}
|
||||
if res.Score != 2 || !got[0] || got[1] || got[2] || !got[3] {
|
||||
t.Errorf("score %d, per question %v; want 2, [true false false true]", res.Score, got)
|
||||
}
|
||||
if n := len(res.Items[0].ChosenOptionIDs); n != 2 {
|
||||
t.Errorf("exact answer stored %d options, want 2 (duplicate dropped)", n)
|
||||
}
|
||||
|
||||
stats, err := svc.Responses(ctx, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Every option of the "extra" question was picked once.
|
||||
for _, o := range stats[2].Options {
|
||||
if o.Picks != 1 {
|
||||
t.Errorf("option %q picks = %d, want 1", o.Body, o.Picks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuiz_TakingFlow(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin, user, other := chatUser(t, pg, "Admin"), chatUser(t, pg, "U"), chatUser(t, pg, "O")
|
||||
|
||||
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||
Title: "Bg basics",
|
||||
Questions: []repository.QuestionInput{
|
||||
withExplanation(q("Who spoke the *Gītā*?", 0, "Kṛṣṇa", "Arjuna", "Sañjaya"), "Kṛṣṇa, to Arjuna."),
|
||||
q("Where?", 1, "Mathurā", "Kurukṣetra"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
|
||||
// Drafts aren't reachable.
|
||||
if _, err := svc.Start(ctx, user, quiz.ID); !errors.Is(err, ErrQuizUnpublished) {
|
||||
t.Fatalf("starting a draft: %v", err)
|
||||
}
|
||||
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
play, err := svc.Start(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if play.Question == nil || play.Total != 2 || play.Answered != 0 {
|
||||
t.Fatalf("first step = %+v", play)
|
||||
}
|
||||
// The correct answer never reaches someone mid-attempt — nor the
|
||||
// explanation, which discusses it.
|
||||
if play.Question.Explanation != "" {
|
||||
t.Error("explanation leaked to a user mid-attempt")
|
||||
}
|
||||
for _, o := range play.Question.Options {
|
||||
if o.IsCorrect != nil {
|
||||
t.Fatal("is_correct leaked to a user mid-attempt")
|
||||
}
|
||||
}
|
||||
// Nor can they read a result yet.
|
||||
if _, err := svc.Result(ctx, user, quiz.ID); !errors.Is(err, repository.ErrQuizNotFinished) {
|
||||
t.Errorf("result mid-attempt: %v, want ErrQuizNotFinished", err)
|
||||
}
|
||||
|
||||
card, _ := svc.Card(ctx, user, quiz.ID)
|
||||
if card.My.State != model.QuizInProgress {
|
||||
t.Errorf("state after start = %s, want in_progress", card.My.State)
|
||||
}
|
||||
|
||||
// Answering out of order (a question not yet served) is refused.
|
||||
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||
second := full.Questions[1]
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, second.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizNotCurrent) {
|
||||
t.Errorf("answering ahead: %v, want ErrQuizNotCurrent", err)
|
||||
}
|
||||
// An option from another question is refused.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizBadOption) {
|
||||
t.Errorf("foreign option: %v, want ErrQuizBadOption", err)
|
||||
}
|
||||
|
||||
// Q1 right, Q2 wrong.
|
||||
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{full.Questions[0].Options[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Can't change an answer.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, full.Questions[0].ID, []string{full.Questions[0].Options[1].ID}); !errors.Is(err, repository.ErrQuizAlreadyAnswer) {
|
||||
t.Errorf("re-answering: %v, want ErrQuizAlreadyAnswer", err)
|
||||
}
|
||||
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !play.Done {
|
||||
t.Fatalf("after the last answer: %+v", play)
|
||||
}
|
||||
|
||||
res, err := svc.Result(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Score != 1 || res.Total != 2 || res.CompletedAt == nil {
|
||||
t.Errorf("result = score %d/%d completed %v", res.Score, res.Total, res.CompletedAt)
|
||||
}
|
||||
for _, it := range res.Items {
|
||||
if it.ResponseMs == nil {
|
||||
t.Error("a response time wasn't recorded")
|
||||
}
|
||||
if len(it.ChosenOptionIDs) != 1 {
|
||||
t.Error("the chosen option wasn't recorded")
|
||||
}
|
||||
}
|
||||
if !res.Items[0].IsCorrect || res.Items[1].IsCorrect {
|
||||
t.Error("per-question correctness is wrong")
|
||||
}
|
||||
|
||||
// Only your own result: someone who never took it has none.
|
||||
if _, err := svc.Result(ctx, other, quiz.ID); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||
t.Errorf("another user's result: %v, want ErrQuizNotStarted", err)
|
||||
}
|
||||
|
||||
// Admin sees the participant and the per-question picks.
|
||||
parts, err := svc.Participants(ctx, quiz.ID)
|
||||
if err != nil || len(parts) != 1 || parts[0].Score != 1 || parts[0].Answered != 2 {
|
||||
t.Errorf("participants = %+v, %v", parts, err)
|
||||
}
|
||||
stats, err := svc.Responses(ctx, quiz.ID)
|
||||
if err != nil || len(stats) != 2 || stats[0].Correct != 1 || stats[1].Correct != 0 {
|
||||
t.Errorf("responses = %+v, %v", stats, err)
|
||||
}
|
||||
|
||||
// Staff can reset one participant: their attempt and answers go, the quiz
|
||||
// is pending for them again, and they can retake it from the start.
|
||||
if err := svc.ResetParticipant(ctx, quiz.ID, other); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||
t.Errorf("resetting someone with no attempt: %v, want ErrQuizNotStarted", err)
|
||||
}
|
||||
if err := svc.ResetParticipant(ctx, quiz.ID, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card, _ := svc.Card(ctx, user, quiz.ID); card.My.State != model.QuizNotStarted {
|
||||
t.Errorf("state after reset = %s, want not_started", card.My.State)
|
||||
}
|
||||
if parts, _ := svc.Participants(ctx, quiz.ID); len(parts) != 0 {
|
||||
t.Errorf("participants after reset = %d, want 0", len(parts))
|
||||
}
|
||||
if again, err := svc.Start(ctx, user, quiz.ID); err != nil || again.Answered != 0 || again.Question == nil {
|
||||
t.Errorf("retaking after reset: %+v, %v", again, err)
|
||||
}
|
||||
|
||||
// Taken, so questions are frozen — but the title can still change.
|
||||
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
||||
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
||||
}
|
||||
if upd, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "Bhagavad-gītā basics"}); err != nil || len(upd.Questions) != 2 {
|
||||
t.Errorf("retitling a taken quiz: %v (questions %d)", err, len(upd.Questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeContentPath(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://prabhupadasays.org/books/bg/2/?x=1#v3": "/books/bg/2",
|
||||
"/transcriptions/720720sb-london": "/transcriptions/720720sb-london",
|
||||
"books/bg/2": "/books/bg/2",
|
||||
"": "",
|
||||
"/": "",
|
||||
} {
|
||||
if got := NormalizeContentPath(in); got != want {
|
||||
t.Errorf("NormalizeContentPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A chapter, lecture or conversation carries at most one live quiz. The
|
||||
// service names the quiz already there; the unique index catches a race;
|
||||
// deleting a quiz frees its link; stand-alone quizzes are unlimited.
|
||||
func TestQuiz_OnePerTarget(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin := chatUser(t, pg, "Admin")
|
||||
repo := repository.NewQuizRepo(pg)
|
||||
|
||||
path := "/books/zz-test/" + uuid.NewString()[:8]
|
||||
in := repository.QuizInput{
|
||||
Title: "First", TargetKind: model.QuizChapter, TargetPath: &path,
|
||||
Questions: []repository.QuestionInput{q("p", 0, "a", "b")},
|
||||
}
|
||||
first, err := repo.Create(ctx, in, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, first)
|
||||
|
||||
// The friendly pre-check names the quiz that holds the link…
|
||||
err = svc.ensureTargetFree(ctx, in, "")
|
||||
if !errors.Is(err, repository.ErrQuizTargetTaken) || !strings.Contains(err.Error(), `"First"`) {
|
||||
t.Errorf("second quiz on a link: %v, want ErrQuizTargetTaken naming \"First\"", err)
|
||||
}
|
||||
// …but a quiz re-saving its own link is fine.
|
||||
if err := svc.ensureTargetFree(ctx, in, first); err != nil {
|
||||
t.Errorf("re-saving the same quiz: %v", err)
|
||||
}
|
||||
// The index refuses it even if the check is skipped (two saves racing).
|
||||
in.Title = "Second"
|
||||
if _, err := repo.Create(ctx, in, admin); !errors.Is(err, repository.ErrQuizTargetTaken) {
|
||||
t.Errorf("create past the index: %v, want ErrQuizTargetTaken", err)
|
||||
}
|
||||
// Moving another quiz onto the link is refused too.
|
||||
other := repository.QuizInput{Title: "Other", TargetKind: model.QuizStandalone,
|
||||
Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}
|
||||
otherID, err := repo.Create(ctx, other, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, otherID)
|
||||
other.TargetKind, other.TargetPath = model.QuizChapter, &path
|
||||
if err := repo.Update(ctx, otherID, other, false); !errors.Is(err, repository.ErrQuizTargetTaken) {
|
||||
t.Errorf("moving a quiz onto a taken link: %v, want ErrQuizTargetTaken", err)
|
||||
}
|
||||
// Stand-alone quizzes are unlimited.
|
||||
if solo, err := svc.Create(ctx, admin, QuizDraft{Title: "Solo", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); err != nil {
|
||||
t.Errorf("a second stand-alone quiz: %v", err)
|
||||
} else {
|
||||
cleanupQuiz(t, pg, solo.ID)
|
||||
}
|
||||
// Deleting the first frees the link.
|
||||
if err := svc.Delete(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Update(ctx, otherID, other, false); err != nil {
|
||||
t.Errorf("taking a freed link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
@@ -64,7 +69,10 @@ type SadhnaService struct {
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
rewards *repository.RewardRepo
|
||||
logger *zap.Logger
|
||||
// r2 stores custom gift artwork. Optional: nil disables custom gifts
|
||||
// rather than failing anything that already works.
|
||||
r2 *repository.R2
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSadhnaService(
|
||||
@@ -72,9 +80,13 @@ func NewSadhnaService(
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
rewards *repository.RewardRepo,
|
||||
r2 *repository.R2,
|
||||
logger *zap.Logger,
|
||||
) *SadhnaService {
|
||||
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
|
||||
return &SadhnaService{
|
||||
logs: logs, posts: posts, users: users, rewards: rewards,
|
||||
r2: r2, logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
const dayFmt = "2006-01-02"
|
||||
@@ -398,6 +410,87 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad
|
||||
|
||||
// AckRewards clears the user's pending celebration(s) after the client has
|
||||
// shown the confetti.
|
||||
// OpenGift un-blurs one gift after the devotee has seen its celebration.
|
||||
//
|
||||
// Per gift, not per user: someone with two waiting should open them one at a
|
||||
// time rather than have the first dismissal clear both.
|
||||
func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error {
|
||||
return s.rewards.OpenGift(ctx, userID, rewardKey)
|
||||
}
|
||||
|
||||
// GiftToEveryone hands a reward to every account. Returns how many it reached.
|
||||
func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) {
|
||||
reward, err := s.rewards.ByKey(ctx, rewardKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
|
||||
}
|
||||
|
||||
// customKeyPrefix namespaces admin-made rewards away from the seeded catalog,
|
||||
// so Seed can never collide with one.
|
||||
const customKeyPrefix = "custom_"
|
||||
|
||||
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// customRewardKey derives a stable, unique key from a name.
|
||||
//
|
||||
// The random suffix is what makes it unique: two gifts may reasonably share a
|
||||
// name, and a collision would overwrite the first one's artwork.
|
||||
func customRewardKey(name string) (string, error) {
|
||||
slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||
if slug == "" {
|
||||
slug = "gift"
|
||||
}
|
||||
if len(slug) > 32 {
|
||||
slug = slug[:32]
|
||||
}
|
||||
suffix := make([]byte, 4)
|
||||
if _, err := crand.Read(suffix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil
|
||||
}
|
||||
|
||||
// CreateCustomGift adds a new sticker to the catalog. It is not granted to
|
||||
// anyone until it is sent.
|
||||
func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("a gift needs a name")
|
||||
}
|
||||
if strings.TrimSpace(imageURL) == "" {
|
||||
return nil, errors.New("a gift needs an image")
|
||||
}
|
||||
key, err := customRewardKey(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL)
|
||||
}
|
||||
|
||||
// PresignGiftImage issues an upload URL for custom sticker artwork.
|
||||
//
|
||||
// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an
|
||||
// absolute address, and stickers render for every participant in a chat, so
|
||||
// the artwork belongs in the public bucket alongside avatars and post images.
|
||||
func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) {
|
||||
return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType)
|
||||
}
|
||||
|
||||
// DeleteCustomGift removes a custom sticker everywhere it is held.
|
||||
func (s *SadhnaService) DeleteCustomGift(ctx context.Context, key string) error {
|
||||
if !strings.HasPrefix(key, customKeyPrefix) {
|
||||
return errors.New("only custom gifts can be deleted")
|
||||
}
|
||||
return s.rewards.DeleteCustom(ctx, key)
|
||||
}
|
||||
|
||||
// GiftableRewards is the catalog an admin picks from.
|
||||
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
|
||||
return s.rewards.ListGiftable(ctx)
|
||||
}
|
||||
|
||||
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
||||
return s.rewards.MarkCelebrated(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -71,10 +71,13 @@ type SocialGraphService struct {
|
||||
reports *repository.ReportRepo
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
// 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
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSocialGraphService(
|
||||
@@ -83,6 +86,7 @@ func NewSocialGraphService(
|
||||
reports *repository.ReportRepo,
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
chat *repository.ChatRepo,
|
||||
notif *NotificationService,
|
||||
mail mailer.Mailer,
|
||||
admins []string,
|
||||
@@ -90,7 +94,8 @@ func NewSocialGraphService(
|
||||
) *SocialGraphService {
|
||||
return &SocialGraphService{
|
||||
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.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
|
||||
}
|
||||
|
||||
|
||||
@@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp
|
||||
// presignImage builds an R2 presign for a user-scoped media key under the
|
||||
// given prefix. The random suffix invalidates browser caches when the image
|
||||
// is replaced.
|
||||
// presignImageKey is presignImage's sibling for callers that store the storage
|
||||
// KEY rather than a URL — so the CDN domain can change without rewriting rows.
|
||||
// Handing those callers a public URL silently doubles it at read time.
|
||||
func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) {
|
||||
if r2 == nil {
|
||||
return "", "", errors.New("media uploads are not configured")
|
||||
}
|
||||
ext, ok := imageExtensionFor(contentType)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
suffix := make([]byte, 8)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext)
|
||||
uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return uploadURL, key, nil
|
||||
}
|
||||
|
||||
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||
if r2 == nil {
|
||||
return "", "", errors.New("media uploads are not configured")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
|
||||
// isn't configured (local development leaves it empty).
|
||||
const defaultAdminURL = "https://admin.prabhupadasays.org"
|
||||
|
||||
// sendStaffWelcome tells someone they've been given admin access, and as
|
||||
// what. Fire-and-forget, like the other notification emails: the grant is
|
||||
// already committed, so a mail failure is logged, never surfaced.
|
||||
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
|
||||
if s.mail == nil {
|
||||
return
|
||||
}
|
||||
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
|
||||
s.logger.Info("staff granted; no email on file, skipping welcome",
|
||||
zap.String("user_id", m.UserID))
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": strings.TrimSpace(m.Name),
|
||||
"RoleName": m.RoleName,
|
||||
"AdminURL": s.adminURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render staff welcome email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
to := strings.TrimSpace(*m.Email)
|
||||
go func() {
|
||||
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("staff welcome email send failed",
|
||||
zap.String("user_id", m.UserID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// staffWelcomeEmailTpl — same styling as the other account emails. The role
|
||||
// name is admin-entered (custom roles) and auto-escaped by html/template.
|
||||
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
|
||||
|
||||
<p>Log in with your email and password at
|
||||
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
|
||||
</p>
|
||||
|
||||
<p>For any queries, contact your co-ordinator.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
@@ -0,0 +1,256 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStaffSelf = errors.New("you can't change your own staff access")
|
||||
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
|
||||
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
|
||||
ErrRoleBadPermission = errors.New("unknown permission")
|
||||
ErrRoleName = errors.New("a role needs a name")
|
||||
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
|
||||
)
|
||||
|
||||
// StaffService manages who can use the admin panel and with what powers.
|
||||
// Every method here is reachable only with rbac.ManageStaff — superadmins.
|
||||
type StaffService struct {
|
||||
staff *repository.StaffRepo
|
||||
// mail tells newly added staff about their access; nil sends nothing.
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
adminURL string
|
||||
}
|
||||
|
||||
// NewStaffService wires staff management. adminURL is the panel's address for
|
||||
// the welcome email; empty falls back to the production panel.
|
||||
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
|
||||
if adminURL == "" {
|
||||
adminURL = defaultAdminURL
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
|
||||
}
|
||||
|
||||
// Me describes the caller, for the panel to decide what to show.
|
||||
type Me struct {
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleName string `json:"role_name"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
|
||||
return s.staff.ListStaff(ctx)
|
||||
}
|
||||
|
||||
// isSuperadminRole reports whether a role id is the seeded superadmin.
|
||||
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
|
||||
ro, err := s.staff.Role(ctx, roleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ro.Key == repository.SuperadminKey, nil
|
||||
}
|
||||
|
||||
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
|
||||
// out of that state if they're the last one — demoting, revoking or blocking
|
||||
// them would leave no one able to manage staff, with no way back from the UI.
|
||||
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
|
||||
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
|
||||
return nil
|
||||
}
|
||||
n, err := s.staff.ActiveSuperadmins(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastSuperadmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Grant gives an account a role, by handle.
|
||||
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
|
||||
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
if _, err := s.staff.Role(ctx, roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Granting to someone who is already staff is a role change, and must
|
||||
// pass the same last-superadmin check.
|
||||
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
|
||||
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
|
||||
}
|
||||
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := s.staff.StaffMember(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only a NEW staff member is welcomed; role changes above stay quiet.
|
||||
s.sendStaffWelcome(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ChangeRole moves a staff member to another role.
|
||||
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
|
||||
if targetID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toSuper, err := s.isSuperadminRole(ctx, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !toSuper {
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.StaffMember(ctx, targetID)
|
||||
}
|
||||
|
||||
// Revoke removes a staff member's access entirely.
|
||||
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
|
||||
if targetID == actorID {
|
||||
return ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.staff.SetRole(ctx, targetID, nil)
|
||||
}
|
||||
|
||||
// SetBlocked suspends or restores a staff member's panel access, keeping their
|
||||
// role so restoring them is one action rather than a re-grant.
|
||||
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
|
||||
if targetID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.StaffMember(ctx, targetID)
|
||||
}
|
||||
|
||||
// ── Roles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
|
||||
return s.staff.ListRoles(ctx)
|
||||
}
|
||||
|
||||
// cleanPermissions validates a custom role's permissions against the catalog.
|
||||
// The wildcard and admins:manage aren't in the catalog, so they are refused
|
||||
// here — a custom role can never become a second superadmin.
|
||||
func cleanPermissions(perms []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
if !rbac.Valid(p) {
|
||||
return nil, ErrRoleBadPermission
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, ErrRoleNoPermissions
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func roleKeyFor(name string) string {
|
||||
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||
if len(k) > 40 {
|
||||
k = k[:40]
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
key := roleKeyFor(name)
|
||||
if name == "" || key == "" {
|
||||
return nil, ErrRoleName
|
||||
}
|
||||
clean, err := cleanPermissions(perms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
|
||||
}
|
||||
|
||||
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
|
||||
ro, err := s.staff.Role(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ro.IsSystem {
|
||||
return nil, ErrRoleSystem
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrRoleName
|
||||
}
|
||||
clean, err := cleanPermissions(perms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.Role(ctx, id)
|
||||
}
|
||||
|
||||
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
|
||||
ro, err := s.staff.Role(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ro.IsSystem {
|
||||
return ErrRoleSystem
|
||||
}
|
||||
return s.staff.DeleteRole(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := pg.Pool.QueryRow(context.Background(),
|
||||
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
|
||||
t.Fatalf("role %s: %v", key, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
|
||||
t.Helper()
|
||||
id := chatUser(t, pg, roleKey)
|
||||
if _, err := pg.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// The floor: removing the last active superadmin would leave nobody able to
|
||||
// manage staff, with no way back from the UI.
|
||||
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
|
||||
_, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
staff := repository.NewStaffRepo(pg)
|
||||
svc := NewStaffService(staff, nil, nil, "")
|
||||
|
||||
// Isolate from any staff already in the dev database: the floor counts
|
||||
// superadmins globally. Parked in a REAL table — a TEMP table is
|
||||
// per-connection, and the pool may run the cleanup on a different one,
|
||||
// which would leave real staff stripped of their roles.
|
||||
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
||||
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
|
||||
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
|
||||
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
|
||||
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
|
||||
t.Errorf("restoring parked staff: %v", err)
|
||||
return
|
||||
}
|
||||
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
|
||||
})
|
||||
|
||||
a := makeStaff(t, pg, "superadmin")
|
||||
b := makeStaff(t, pg, "superadmin")
|
||||
mod := makeStaff(t, pg, "moderator")
|
||||
|
||||
// Nobody acts on themselves.
|
||||
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
|
||||
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
|
||||
}
|
||||
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
|
||||
t.Errorf("self-block: %v, want ErrStaffSelf", err)
|
||||
}
|
||||
|
||||
// A superadmin can block another admin/mod — and another superadmin while
|
||||
// one remains.
|
||||
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
|
||||
t.Errorf("block moderator: %v", err)
|
||||
}
|
||||
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
|
||||
t.Errorf("block second superadmin: %v", err)
|
||||
}
|
||||
// Now a is the only active one: a blocked b can't be used to demote a.
|
||||
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
|
||||
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||
}
|
||||
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
|
||||
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
|
||||
}
|
||||
|
||||
// A blocked staff member is turned away at the door.
|
||||
acc, err := staff.Access(ctx, mod)
|
||||
if err != nil || !acc.Blocked {
|
||||
t.Errorf("blocked moderator access = %+v, %v", acc, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom role is built only from the catalog — never the wildcard or
|
||||
// admins:manage, which would make it a second superadmin.
|
||||
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
|
||||
_, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
|
||||
|
||||
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
|
||||
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
|
||||
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
|
||||
}
|
||||
}
|
||||
|
||||
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
|
||||
|
||||
set := rbac.NewSet(ro.Permissions)
|
||||
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
|
||||
t.Errorf("custom role permissions resolved wrong: %v", set.List())
|
||||
}
|
||||
|
||||
// System roles are read-only.
|
||||
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
|
||||
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
|
||||
}
|
||||
}
|
||||
|
||||
// captureMailer records sends; the welcome email goes out on a goroutine.
|
||||
type captureMailer struct{ sent chan [3]string }
|
||||
|
||||
func (m *captureMailer) Send(to, subject, body string) error {
|
||||
m.sent <- [3]string{to, subject, body}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Adding someone as staff emails them their role and where to log in; moving
|
||||
// an existing staff member to another role does not.
|
||||
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
|
||||
_, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
mail := &captureMailer{sent: make(chan [3]string, 4)}
|
||||
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
|
||||
|
||||
actor := chatUser(t, pg, "Actor")
|
||||
target, handle := chatUserWithHandle(t, pg, "Target")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
|
||||
})
|
||||
|
||||
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got := <-mail.sent:
|
||||
if !strings.HasPrefix(got[0], "chatsvc-") {
|
||||
t.Errorf("sent to %q, want the target's address", got[0])
|
||||
}
|
||||
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
|
||||
if !strings.Contains(got[2], want) {
|
||||
t.Errorf("email body missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got[1], "Moderator") {
|
||||
t.Errorf("subject %q doesn't name the role", got[1])
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no welcome email was sent")
|
||||
}
|
||||
|
||||
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got := <-mail.sent:
|
||||
t.Errorf("a role change sent an email: %q", got[1])
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +34,13 @@ func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService
|
||||
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||
// content to pick.
|
||||
var (
|
||||
// ErrVaniBadKind is a kind outside verse/prose/transcript.
|
||||
ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
|
||||
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
|
||||
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
|
||||
)
|
||||
|
||||
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||
now := time.Now().UTC()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
@@ -75,6 +85,136 @@ func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||
|
||||
// choose tries the three content kinds in random order and returns the first
|
||||
// successful sample, so each day's pick varies across content types.
|
||||
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
|
||||
//
|
||||
// The intended admin workflow: paste the link to the thing you want. Search
|
||||
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
|
||||
// matches on "london" and returns unrelated lectures.
|
||||
//
|
||||
// Accepts an absolute URL or a path, and understands the three shapes the
|
||||
// vāṇī can point at:
|
||||
//
|
||||
// /transcriptions/{slug}
|
||||
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
|
||||
// /books/{book}/{slug} (prose chapter)
|
||||
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||
day = day.UTC().Truncate(24 * time.Hour)
|
||||
|
||||
path := strings.TrimSpace(raw)
|
||||
if u, err := url.Parse(path); err == nil && u.Host != "" {
|
||||
path = u.Path
|
||||
}
|
||||
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, ErrVaniBadURL
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "transcriptions":
|
||||
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transcriptPick(t, day), nil
|
||||
|
||||
case "books":
|
||||
book := parts[1]
|
||||
nums := parts[2:]
|
||||
// A trailing division segment (SB cantos, CC līlās) shifts the
|
||||
// chapter/verse pair along by one.
|
||||
var division *string
|
||||
if len(nums) == 3 {
|
||||
d := nums[0]
|
||||
division, nums = &d, nums[1:]
|
||||
}
|
||||
if len(nums) == 2 {
|
||||
chapter, err1 := strconv.Atoi(nums[0])
|
||||
verse, err2 := strconv.Atoi(nums[1])
|
||||
if err1 == nil && err2 == nil {
|
||||
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return versePick(v, day), nil
|
||||
}
|
||||
}
|
||||
// Not numeric, so it is a prose chapter's bare slug.
|
||||
if len(nums) == 1 {
|
||||
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
|
||||
}
|
||||
}
|
||||
return nil, ErrVaniBadURL
|
||||
}
|
||||
|
||||
// SetByURL resolves a URL and pins the result.
|
||||
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
|
||||
pick, err := s.ResolveURL(ctx, raw, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pick, nil
|
||||
}
|
||||
|
||||
// SetForDay pins a specific piece of content as a given day's Vāṇī.
|
||||
//
|
||||
// Takes only a kind and a content id — never a payload. The display blob is
|
||||
// rebuilt here with the same builders the random picker uses, so a pinned pick
|
||||
// is indistinguishable from an automatic one and a client cannot dictate what
|
||||
// the home page says.
|
||||
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
|
||||
day = day.UTC().Truncate(24 * time.Hour)
|
||||
|
||||
var pick *model.VaniPick
|
||||
switch kind {
|
||||
case "verse":
|
||||
v, err := s.repo.VerseByHex(ctx, hex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pick = versePick(v, day)
|
||||
case "prose":
|
||||
p, err := s.repo.ProseByHex(ctx, hex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
||||
case "transcript":
|
||||
t, err := s.repo.TranscriptByHex(ctx, hex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pick = transcriptPick(t, day)
|
||||
default:
|
||||
return nil, ErrVaniBadKind
|
||||
}
|
||||
|
||||
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pick, nil
|
||||
}
|
||||
|
||||
// ClearForDay drops a pinned pick, handing the day back to the random picker.
|
||||
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
|
||||
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
|
||||
}
|
||||
|
||||
// Schedule lists decided days from today onward, for the admin view.
|
||||
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
|
||||
if limit <= 0 || limit > 60 {
|
||||
limit = 30
|
||||
}
|
||||
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
|
||||
}
|
||||
|
||||
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
||||
kinds := []string{"verse", "prose", "transcript"}
|
||||
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS chat_settings;
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS broadcast_views;
|
||||
DROP TABLE IF EXISTS broadcasts;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Emoji reactions on chat messages.
|
||||
--
|
||||
-- One reaction per person per message: reacting again REPLACES rather than
|
||||
-- accumulates, which is what the primary key enforces. That matches how the
|
||||
-- UI reads — a row of distinct emoji with counts — and stops someone stacking
|
||||
-- twenty reactions on one bubble.
|
||||
--
|
||||
-- The emoji is stored as text rather than a code point or shortname: what the
|
||||
-- sender picked is what everyone should see, and a mapping table would need
|
||||
-- updating every time the emoji set grows.
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Denormalized so deleting a conversation takes its reactions with it
|
||||
-- without relying on the messages cascade alone.
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
emoji VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
-- A stray empty string would render as an invisible reaction chip.
|
||||
CONSTRAINT message_reactions_emoji_not_blank CHECK (btrim(emoji) <> '')
|
||||
);
|
||||
|
||||
-- Hydrating a page of messages: one index range scan per batch.
|
||||
CREATE INDEX IF NOT EXISTS message_reactions_message_idx
|
||||
ON message_reactions (message_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
DELETE FROM message_attachments WHERE kind = 'sticker';
|
||||
|
||||
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
|
||||
ALTER TABLE message_attachments
|
||||
ADD CONSTRAINT message_attachments_kind_check
|
||||
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote'));
|
||||
|
||||
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
|
||||
ALTER TABLE message_attachments
|
||||
ADD CONSTRAINT message_attachments_entity_type_check
|
||||
CHECK (entity_type IS NULL OR
|
||||
entity_type IN ('post', 'comment', 'profile', 'collection'));
|
||||
|
||||
DROP TABLE IF EXISTS saved_stickers;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Stickers in chat.
|
||||
--
|
||||
-- A sticker IS a sādhana reward — same catalog, same artwork — so there is no
|
||||
-- separate sticker table. What's new is a second way to come by one: someone
|
||||
-- sends you theirs and you keep it.
|
||||
--
|
||||
-- saved_stickers records only that second route. A reward you earned is
|
||||
-- already yours through sadhna_reward_grants, and duplicating it here would
|
||||
-- give two answers to "do I own this?".
|
||||
CREATE TABLE IF NOT EXISTS saved_stickers (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reward_id UUID NOT NULL REFERENCES sadhna_rewards(id) ON DELETE CASCADE,
|
||||
-- Who sent it. Kept for provenance, and SET NULL rather than CASCADE so
|
||||
-- deleting an account doesn't take away stickers other people saved.
|
||||
saved_from UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, reward_id)
|
||||
);
|
||||
|
||||
-- "What's in my sticker drawer?" — the whole reason this table exists.
|
||||
CREATE INDEX IF NOT EXISTS saved_stickers_user_idx
|
||||
ON saved_stickers (user_id, created_at DESC);
|
||||
|
||||
-- A sticker message: kind 'sticker', with the artwork denormalized onto the
|
||||
-- attachment's existing image_url/title columns.
|
||||
--
|
||||
-- Denormalized rather than resolved per read, unlike a shared post: the
|
||||
-- catalog is static art with no viewer-dependent visibility, so there is
|
||||
-- nothing to re-check and a join on every message would buy nothing.
|
||||
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check;
|
||||
|
||||
ALTER TABLE message_attachments
|
||||
ADD CONSTRAINT message_attachments_kind_check
|
||||
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote', 'sticker'));
|
||||
|
||||
-- entity_id carries the reward key on a sticker attachment, so a recipient can
|
||||
-- save it without the client having to trust a URL it was handed.
|
||||
ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check;
|
||||
|
||||
ALTER TABLE message_attachments
|
||||
ADD CONSTRAINT message_attachments_entity_type_check
|
||||
CHECK (entity_type IS NULL OR
|
||||
entity_type IN ('post', 'comment', 'profile', 'collection', 'sticker'));
|
||||
@@ -0,0 +1,12 @@
|
||||
DELETE FROM sadhna_reward_grants WHERE source = 'gift';
|
||||
|
||||
DROP INDEX IF EXISTS sadhna_reward_grants_unopened_idx;
|
||||
DROP INDEX IF EXISTS sadhna_reward_grants_gift_uniq;
|
||||
DROP INDEX IF EXISTS sadhna_reward_grants_streak_uniq;
|
||||
|
||||
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone SET NOT NULL;
|
||||
ALTER TABLE sadhna_reward_grants
|
||||
ADD CONSTRAINT sadhna_reward_grants_user_id_milestone_key UNIQUE (user_id, milestone);
|
||||
|
||||
ALTER TABLE sadhna_reward_grants DROP CONSTRAINT IF EXISTS sadhna_reward_grants_source_check;
|
||||
ALTER TABLE sadhna_reward_grants DROP COLUMN IF EXISTS source;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Occasional gift rewards, granted from the admin panel.
|
||||
--
|
||||
-- A gift IS a reward grant — same catalog, same gallery, same vault — so it
|
||||
-- reuses sadhna_reward_grants rather than introducing a parallel table that
|
||||
-- every reader would then have to union in. What differs is only where it came
|
||||
-- from, which `source` records.
|
||||
--
|
||||
-- `celebrated_at` already means "the devotee has seen this", which is exactly
|
||||
-- the claim flag a gift needs: unclaimed gifts render blurred, and dismissing
|
||||
-- the celebration is what clears them.
|
||||
ALTER TABLE sadhna_reward_grants
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(8) NOT NULL DEFAULT 'streak';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sadhna_reward_grants_source_check') THEN
|
||||
ALTER TABLE sadhna_reward_grants
|
||||
ADD CONSTRAINT sadhna_reward_grants_source_check
|
||||
CHECK (source IN ('streak', 'gift'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- A gift is not tied to a streak day, so it has no milestone.
|
||||
ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone DROP NOT NULL;
|
||||
|
||||
-- UNIQUE (user_id, milestone) was the streak rule: one reward per milestone.
|
||||
-- It can't police gifts, which have no milestone — so it becomes a partial
|
||||
-- index over streak rows, and gifts get their own rule: one of each reward
|
||||
-- per person, so re-gifting is idempotent rather than a duplicate tile.
|
||||
ALTER TABLE sadhna_reward_grants
|
||||
DROP CONSTRAINT IF EXISTS sadhna_reward_grants_user_id_milestone_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_streak_uniq
|
||||
ON sadhna_reward_grants (user_id, milestone) WHERE source = 'streak';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_gift_uniq
|
||||
ON sadhna_reward_grants (user_id, reward_id) WHERE source = 'gift';
|
||||
|
||||
-- "Which gifts is this devotee still to open?" — the blurred tiles.
|
||||
CREATE INDEX IF NOT EXISTS sadhna_reward_grants_unopened_idx
|
||||
ON sadhna_reward_grants (user_id) WHERE celebrated_at IS NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Restore the old flag for anyone who still has a role, so a rollback keeps
|
||||
-- them in the panel.
|
||||
UPDATE users SET role = 'admin' WHERE admin_role_id IS NOT NULL AND admin_blocked_at IS NULL;
|
||||
DROP INDEX IF EXISTS users_admin_role_idx;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS admin_blocked_at;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS admin_role_id;
|
||||
DROP TABLE IF EXISTS admin_roles;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Role-based access for the admin panel.
|
||||
--
|
||||
-- Until now admin access was one flag: users.role = 'admin'. This replaces it
|
||||
-- with named roles carrying explicit permissions, so a moderator can resolve
|
||||
-- reports without also being able to ban users or gift stickers to everyone.
|
||||
--
|
||||
-- Three system roles are seeded and cannot be edited or deleted; superadmins
|
||||
-- can define further custom roles from any subset of the permission catalog
|
||||
-- (which lives in code — see internal/rbac). The one thing a custom role can
|
||||
-- never hold is admins:manage: granting, changing, revoking and blocking staff
|
||||
-- stays with superadmins.
|
||||
CREATE TABLE IF NOT EXISTS admin_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key VARCHAR(40) NOT NULL UNIQUE,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- System roles are seeded below and are read-only.
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- "resource:action" strings, or '*' for everything (superadmin only).
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO admin_roles (key, name, description, is_system, permissions) VALUES
|
||||
('superadmin', 'Superadmin',
|
||||
'Everything, including managing other staff.', TRUE, ARRAY['*']),
|
||||
('admin', 'Admin',
|
||||
'Runs the platform day to day. Cannot manage staff.', TRUE, ARRAY[
|
||||
'stats:read','users:read','users:notify','users:ban','users:limits',
|
||||
'disciples:manage','posts:moderate','comments:moderate','collections:read',
|
||||
'feedback:read','feedback:reply','feedback:resolve',
|
||||
'reports:read','reports:resolve','vani:read','vani:write',
|
||||
'rewards:read','rewards:write','rewards:send','rewards:delete',
|
||||
'broadcasts:read','broadcasts:write','broadcasts:publish',
|
||||
'short_urls:read','quizzes:read','quizzes:write','quizzes:delete','quizzes:results']),
|
||||
('moderator', 'Moderator',
|
||||
'Keeps the community healthy: reports, moderation and feedback.', TRUE, ARRAY[
|
||||
'stats:read','users:read','posts:moderate','comments:moderate',
|
||||
'collections:read','feedback:read','feedback:reply','feedback:resolve',
|
||||
'reports:read','reports:resolve'])
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Staff membership. NULL = not staff.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS admin_role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT;
|
||||
|
||||
-- A superadmin can suspend another staff member's panel access without taking
|
||||
-- their role away, so restoring them is one click rather than a re-grant.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_blocked_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_admin_role_idx
|
||||
ON users (admin_role_id) WHERE admin_role_id IS NOT NULL;
|
||||
|
||||
-- Everyone who was an admin under the old single flag becomes a superadmin:
|
||||
-- they had unrestricted access before, so anything less would lock existing
|
||||
-- staff out of the panel the moment this deploys.
|
||||
UPDATE users
|
||||
SET admin_role_id = (SELECT id FROM admin_roles WHERE key = 'superadmin')
|
||||
WHERE role = 'admin' AND admin_role_id IS NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS quiz_answers;
|
||||
DROP TABLE IF EXISTS quiz_attempts;
|
||||
DROP TABLE IF EXISTS quiz_options;
|
||||
DROP TABLE IF EXISTS quiz_questions;
|
||||
DROP TABLE IF EXISTS quizzes;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Quizzes on the library's content.
|
||||
--
|
||||
-- A quiz is attached to a piece of content by its app path — a book chapter
|
||||
-- (/books/bg/2), or a lecture or conversation (/transcriptions/{slug}) — or to
|
||||
-- nothing at all, for a stand-alone quiz. The path is what the content page
|
||||
-- already knows about itself, so it can ask "is there a quiz for me?" without
|
||||
-- the content's Mongo id ever reaching Postgres.
|
||||
CREATE TABLE IF NOT EXISTS quizzes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
-- Light formatting markers, rendered like the rest of the admin text.
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- chapter | lecture | conversation | standalone
|
||||
target_kind VARCHAR(16) NOT NULL DEFAULT 'standalone',
|
||||
-- App path of the content, NULL for a stand-alone quiz.
|
||||
target_path TEXT,
|
||||
-- Display name of the content, captured when the quiz is made.
|
||||
target_title TEXT NOT NULL DEFAULT '',
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Soft delete: participants' results stay intact for the record.
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT quizzes_target_kind_check
|
||||
CHECK (target_kind IN ('chapter', 'lecture', 'conversation', 'standalone')),
|
||||
CONSTRAINT quizzes_target_consistent
|
||||
CHECK ((target_kind = 'standalone') = (target_path IS NULL))
|
||||
);
|
||||
|
||||
-- "Is there a quiz for this page?" — asked by every content page load.
|
||||
CREATE INDEX IF NOT EXISTS quizzes_target_idx
|
||||
ON quizzes (target_path, published_at DESC)
|
||||
WHERE deleted_at IS NULL AND published_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quiz_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
|
||||
ord SMALLINT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
-- Optional note shown with the answer on the results screen.
|
||||
explanation TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (quiz_id, ord)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quiz_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
|
||||
ord SMALLINT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (question_id, ord),
|
||||
-- Five options at most; the service also enforces two at least and
|
||||
-- exactly one correct.
|
||||
CONSTRAINT quiz_options_ord_check CHECK (ord BETWEEN 0 AND 4)
|
||||
);
|
||||
|
||||
-- One attempt per person per quiz: it is what "Continue" resumes and what
|
||||
-- "View results" shows.
|
||||
CREATE TABLE IF NOT EXISTS quiz_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (quiz_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS quiz_attempts_user_idx ON quiz_attempts (user_id);
|
||||
|
||||
-- One row per question an attempt has reached.
|
||||
--
|
||||
-- Timing is measured by the SERVER: shown_at is stamped when the question is
|
||||
-- handed to the client, answered_at when the answer arrives. A client-reported
|
||||
-- duration could be anything; these two timestamps can't be edited from the
|
||||
-- browser.
|
||||
CREATE TABLE IF NOT EXISTS quiz_answers (
|
||||
attempt_id UUID NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
|
||||
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
|
||||
option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL,
|
||||
shown_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
answered_at TIMESTAMPTZ,
|
||||
response_ms INTEGER,
|
||||
is_correct BOOLEAN,
|
||||
PRIMARY KEY (attempt_id, question_id)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL;
|
||||
-- Lossy for multi answers: keeps the first chosen option only.
|
||||
UPDATE quiz_answers SET option_id = option_ids[1] WHERE option_ids IS NOT NULL;
|
||||
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_ids;
|
||||
ALTER TABLE quiz_questions DROP COLUMN IF EXISTS multi;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Multiple-choice questions with more than one correct answer.
|
||||
--
|
||||
-- A question marked multi asks the reader to "select all that apply"; it is
|
||||
-- scored correct only when the chosen set equals the correct set exactly. A
|
||||
-- single-answer question keeps exactly one correct option.
|
||||
ALTER TABLE quiz_questions ADD COLUMN IF NOT EXISTS multi BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- An answer is now a set of options. Arrays can't carry a foreign key; that
|
||||
-- is safe here because a quiz's options are frozen once anyone has answered,
|
||||
-- and options only ever go away with their whole question.
|
||||
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_ids UUID[];
|
||||
UPDATE quiz_answers SET option_ids = ARRAY[option_id]
|
||||
WHERE option_id IS NOT NULL AND option_ids IS NULL;
|
||||
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_id;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE admin_roles SET permissions = array_remove(permissions, 'quizzes:reset');
|
||||
@@ -0,0 +1,6 @@
|
||||
-- quizzes:reset — delete one participant's attempt so the quiz is pending
|
||||
-- for them again. Granted to the seeded Admin role (Superadmin has '*';
|
||||
-- Moderator and custom roles get it only when a superadmin adds it).
|
||||
UPDATE admin_roles
|
||||
SET permissions = array_append(permissions, 'quizzes:reset')
|
||||
WHERE key = 'admin' AND NOT ('quizzes:reset' = ANY(permissions));
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS quizzes_one_per_target;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- One quiz per piece of content. A chapter, lecture or conversation may carry
|
||||
-- at most one live quiz; stand-alone quizzes (no path) are unlimited, and a
|
||||
-- deleted quiz frees its link for a new one.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS quizzes_one_per_target
|
||||
ON quizzes (target_path)
|
||||
WHERE deleted_at IS NULL AND target_path IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE quizzes DROP COLUMN IF EXISTS target_year;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The year of a lecture or conversation a quiz is attached to, captured when
|
||||
-- the quiz is saved, so the app can group quizzes by year without looking
|
||||
-- each transcript up. NULL for chapters, stand-alone quizzes, and undated
|
||||
-- recordings.
|
||||
ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS target_year SMALLINT;
|
||||
@@ -0,0 +1,2 @@
|
||||
UPDATE admin_roles SET permissions = array_remove(permissions, 'feedback:categories');
|
||||
DROP TABLE IF EXISTS feedback_categories;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Feedback categories, managed from the admin panel instead of hard-coded in
|
||||
-- the app and the BFF.
|
||||
--
|
||||
-- `key` is what feedback.category stores, and never changes once created —
|
||||
-- so renaming a category in the admin relabels every past submission too.
|
||||
-- Archiving hides a category from the /feedback form while existing feedback
|
||||
-- keeps its label. There is deliberately no foreign key from feedback: the
|
||||
-- column predates this table, and the admin only hard-deletes categories
|
||||
-- nothing uses.
|
||||
CREATE TABLE IF NOT EXISTS feedback_categories (
|
||||
key TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The four categories the app offered until now, with the exact labels and
|
||||
-- descriptions its form showed, in the form's order. Production feedback
|
||||
-- already uses these keys, so they must stay byte-for-byte the same.
|
||||
INSERT INTO feedback_categories (key, label, description, sort_order) VALUES
|
||||
('general', 'General feedback', 'Thoughts, impressions, suggestions — anything else.', 0),
|
||||
('bug', 'Bug report', 'Something is broken or behaving unexpectedly.', 1),
|
||||
('feature', 'Feature request', 'An idea for something new on the site.', 2),
|
||||
('other', 'Other', 'Doesn''t fit the above? Use this.', 3)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Safety net: any other key already present in feedback (from an older
|
||||
-- client) gets a category too — archived, so it isn't offered on the form,
|
||||
-- but its submissions still carry a readable label.
|
||||
INSERT INTO feedback_categories (key, label, sort_order, archived_at)
|
||||
SELECT DISTINCT f.category, initcap(replace(f.category, '_', ' ')), 100, now()
|
||||
FROM feedback f
|
||||
WHERE NOT EXISTS (SELECT 1 FROM feedback_categories c WHERE c.key = f.category)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- The Admin role may manage them; Superadmin already has '*'.
|
||||
UPDATE admin_roles
|
||||
SET permissions = array_append(permissions, 'feedback:categories')
|
||||
WHERE key = 'admin' AND NOT ('feedback:categories' = ANY(permissions));
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package richtext renders the light formatting the admin panel's message
|
||||
// fields allow, for the two places that text leaves the app:
|
||||
//
|
||||
// - ToHTML, for email bodies.
|
||||
// - Plain, for push notifications and toasts, which can't render markup.
|
||||
//
|
||||
// The format is a handful of markers typed into a plain textarea:
|
||||
//
|
||||
// *bold* _italic_ __underline__ ~strike~ [label](https://…)
|
||||
//
|
||||
// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~
|
||||
// match what chat already uses, so the two read the same.
|
||||
//
|
||||
// Safety comes from the order of operations: the input is HTML-escaped FIRST,
|
||||
// and only then are the markers turned into tags. Nothing an author types can
|
||||
// therefore produce markup of its own — the only tags in the output are the
|
||||
// ones this package writes. Links are limited to http and https, so a
|
||||
// `javascript:` URL is never made clickable.
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// A labelled link, or a bare URL. One alternation so a URL is never
|
||||
// linked twice — once as the label's target and once on its own.
|
||||
linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`)
|
||||
|
||||
underlineRe = regexp.MustCompile(`__([^_\n]+?)__`)
|
||||
boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`)
|
||||
strikeRe = regexp.MustCompile(`~([^~\n]+?)~`)
|
||||
// Italic needs a non-word character (or an edge) on each side, so the
|
||||
// underscores inside snake_case or a handle aren't read as markers. RE2
|
||||
// has no lookbehind, so the neighbours are captured and put back.
|
||||
italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`)
|
||||
|
||||
// Punctuation that ends a sentence rather than belonging to the URL.
|
||||
trailingPunct = ".,;:!?)"
|
||||
)
|
||||
|
||||
// placeholder marks where a finished link sits while markers are applied, so
|
||||
// the underscores and tildes common in URLs aren't read as formatting.
|
||||
func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) }
|
||||
|
||||
// splitTrailing separates sentence punctuation from the end of a bare URL.
|
||||
func splitTrailing(u string) (url, tail string) {
|
||||
end := len(u)
|
||||
for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) {
|
||||
end--
|
||||
}
|
||||
return u[:end], u[end:]
|
||||
}
|
||||
|
||||
// ToHTML renders the markers as HTML for an email body.
|
||||
func ToHTML(s string) template.HTML {
|
||||
escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n"))
|
||||
|
||||
var links []string
|
||||
withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string {
|
||||
parts := linkRe.FindStringSubmatch(m)
|
||||
var label, href, tail string
|
||||
if parts[1] != "" {
|
||||
label, href = parts[1], parts[2]
|
||||
} else {
|
||||
href, tail = splitTrailing(parts[3])
|
||||
label = href
|
||||
}
|
||||
// href is already escaped (it came from the escaped input), so it is
|
||||
// safe inside the attribute as-is.
|
||||
links = append(links, fmt.Sprintf(
|
||||
`<a href="%s" style="color:#9a6b2f;font-weight:600">%s</a>`, href, label))
|
||||
return placeholder(len(links)-1) + tail
|
||||
})
|
||||
|
||||
out := underlineRe.ReplaceAllString(withHolders, `<u>$1</u>`)
|
||||
out = boldRe.ReplaceAllString(out, `<strong>$1</strong>`)
|
||||
out = strikeRe.ReplaceAllString(out, `<s>$1</s>`)
|
||||
out = italicRe.ReplaceAllString(out, `$1<em>$2</em>$3`)
|
||||
|
||||
for i, l := range links {
|
||||
out = strings.Replace(out, placeholder(i), l, 1)
|
||||
}
|
||||
out = strings.ReplaceAll(out, "\n", "<br>")
|
||||
|
||||
// Every tag here was written by this function, and all author text was
|
||||
// escaped before any of them were added.
|
||||
return template.HTML(out) //nolint:gosec // see above
|
||||
}
|
||||
|
||||
// Plain strips the markers for surfaces that can't render them — a push
|
||||
// notification or a toast. A labelled link keeps only its label; a bare URL
|
||||
// stays as written, since it is still useful to see.
|
||||
func Plain(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
|
||||
var links []string
|
||||
withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
parts := linkRe.FindStringSubmatch(m)
|
||||
if parts[1] != "" {
|
||||
links = append(links, parts[1])
|
||||
} else {
|
||||
links = append(links, parts[3])
|
||||
}
|
||||
return placeholder(len(links) - 1)
|
||||
})
|
||||
|
||||
out := underlineRe.ReplaceAllString(withHolders, `$1`)
|
||||
out = boldRe.ReplaceAllString(out, `$1`)
|
||||
out = strikeRe.ReplaceAllString(out, `$1`)
|
||||
out = italicRe.ReplaceAllString(out, `$1$2$3`)
|
||||
|
||||
for i, l := range links {
|
||||
out = strings.Replace(out, placeholder(i), l, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToHTML_Markers(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"*bold*", "<strong>bold</strong>"},
|
||||
{"_italic_", "<em>italic</em>"},
|
||||
{"__under__", "<u>under</u>"},
|
||||
{"~gone~", "<s>gone</s>"},
|
||||
{"a *b* and _c_", "a <strong>b</strong> and <em>c</em>"},
|
||||
{"line one\nline two", "line one<br>line two"},
|
||||
// Underscores inside words are not italics.
|
||||
{"snake_case_name", "snake_case_name"},
|
||||
{"@some_handle here", "@some_handle here"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := string(ToHTML(c.in)); got != c.want {
|
||||
t.Errorf("ToHTML(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML_Links(t *testing.T) {
|
||||
got := string(ToHTML("see [the gītā](https://prabhupadasays.org/books/bg)"))
|
||||
if !strings.Contains(got, `href="https://prabhupadasays.org/books/bg"`) ||
|
||||
!strings.Contains(got, ">the gītā</a>") {
|
||||
t.Errorf("labelled link: %q", got)
|
||||
}
|
||||
|
||||
// A bare URL becomes a link, but sentence punctuation stays outside it.
|
||||
got = string(ToHTML("Visit https://prabhupadasays.org."))
|
||||
if !strings.Contains(got, `href="https://prabhupadasays.org"`) ||
|
||||
!strings.HasSuffix(got, "</a>.") {
|
||||
t.Errorf("bare link with trailing period: %q", got)
|
||||
}
|
||||
|
||||
// Underscores and tildes inside a URL are not formatting.
|
||||
got = string(ToHTML("https://example.com/a_b_c~d"))
|
||||
if strings.Contains(got, "<em>") || strings.Contains(got, "<s>") {
|
||||
t.Errorf("markers applied inside a URL: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The security property: nothing typed can become markup of its own.
|
||||
func TestToHTML_EscapesAuthorInput(t *testing.T) {
|
||||
cases := []string{
|
||||
`<script>alert(1)</script>`,
|
||||
`<img src=x onerror=alert(1)>`,
|
||||
`*<b>bold</b>*`,
|
||||
`[click](javascript:alert(1))`,
|
||||
`[x](https://ok.example/" onmouseover="alert(1))`,
|
||||
}
|
||||
for _, in := range cases {
|
||||
got := string(ToHTML(in))
|
||||
if strings.Contains(got, "<script") || strings.Contains(got, "<img") ||
|
||||
strings.Contains(got, "<b>") || strings.Contains(got, "javascript:alert") && strings.Contains(got, "href=\"javascript") {
|
||||
t.Errorf("ToHTML(%q) produced author markup: %q", in, got)
|
||||
}
|
||||
if strings.Contains(got, `" onmouseover=`) {
|
||||
t.Errorf("ToHTML(%q) let a quote break out of an attribute: %q", in, got)
|
||||
}
|
||||
}
|
||||
// A javascript: URL is never made a link at all.
|
||||
if got := string(ToHTML(`[click](javascript:alert(1))`)); strings.Contains(got, "<a ") {
|
||||
t.Errorf("javascript: URL was linked: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlain(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"*New:* _chat_ is __here__ ~soon~", "New: chat is here soon"},
|
||||
{"read [the gītā](https://x.example)", "read the gītā"},
|
||||
{"see https://prabhupadasays.org", "see https://prabhupadasays.org"},
|
||||
{"snake_case stays", "snake_case stays"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Plain(c.in); got != c.want {
|
||||
t.Errorf("Plain(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])) + "…"
|
||||
}
|
||||
@@ -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ABC;tā 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 & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user