fix: hide suspended account

This commit is contained in:
Krispire Admin
2026-08-31 12:28:25 +05:30
parent ac92bc2cf3
commit 44fd1d69ab
8 changed files with 345 additions and 9 deletions
+13 -7
View File
@@ -82,13 +82,18 @@ const searchStopwordsSet = "common"
// searchResultItem represents a single search result in the API response.
type searchResultItem struct {
Collection string `json:"collection"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
Collection string `json:"collection"`
// DocID is the underlying Mongo _id hex — the indexer uses it as the
// Typesense document id. Surfaced so the admin panel can pin a specific
// item as the Vāṇī of the Day; it identifies public content, not anything
// private.
DocID string `json:"doc_id,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
}
// searchResponse is the shape of the data field in the API response.
@@ -349,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
item := searchResultItem{
Collection: collection,
DocID: getStringField(doc, "id"),
URL: getStringField(doc, "url"),
score: score,
}
+94
View File
@@ -1,8 +1,15 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
@@ -18,6 +25,93 @@ 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,
})
}
// 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):
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 {
+2
View File
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
END AS rank
FROM users u
WHERE u.id <> $1
-- A suspended account can't be @mentioned into a post.
AND u.banned_at IS NULL
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`,
+13 -2
View File
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// enough at our user-table scale; revisit alongside the pg_trgm switch.
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
`SELECT COUNT(*) FROM users
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
substring).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count search users: %w", err)
}
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
// and name could reshuffle between pages.
sql := `SELECT ` + userColumns + `
FROM users
WHERE name ILIKE $1 OR handle::text ILIKE $1
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
-- Suspended accounts are unsearchable. This one query backs the
-- people search page, the mention autocomplete AND both chat member
-- pickers, so a banned account appearing here is offered as someone
-- to @mention or add to a group. ResolveHandles already refuses them
-- on the way in, which without this makes "Add" silently do nothing.
AND banned_at IS NULL
ORDER BY
CASE
WHEN handle::text ILIKE $2 THEN 0
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
FROM users u
WHERE u.created_at >= $2
AND u.id <> $1
-- Suspended accounts are not discoverable. A ban hides someone from
-- the app; surfacing them in the welcome strip would be the most
-- prominent placement on the home page.
AND u.banned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id
+109
View File
@@ -16,6 +16,13 @@ import (
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
// verses / prose_chapters / transcriptions collections).
var (
// ErrVaniRefInvalid is a malformed content id.
ErrVaniRefInvalid = errors.New("not a valid content reference")
// ErrVaniRefNotFound is a well-formed id that matches nothing.
ErrVaniRefNotFound = errors.New("that content no longer exists")
)
type VaniRepo struct {
pg *Postgres
mongo *MongoDB
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
return nil
}
// Upsert writes a day's pick, REPLACING any existing one.
//
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
// picker must never overwrite a day already decided. An admin choosing the day
// deliberately is the one case that should win.
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
b, err := json.Marshal(pick.Payload)
if err != nil {
return err
}
_, err = r.pg.Pool.Exec(ctx,
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (day) DO UPDATE
SET kind = EXCLUDED.kind,
ref_id = EXCLUDED.ref_id,
payload = EXCLUDED.payload`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("upsert vani pick: %w", err)
}
return nil
}
// Clear removes a day's pick, so the automatic picker chooses again.
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
if err != nil {
return fmt.Errorf("clear vani pick: %w", err)
}
return nil
}
// PicksFrom lists decided days from `from` onward, for the admin schedule.
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
if err != nil {
return nil, fmt.Errorf("list vani picks: %w", err)
}
defer rows.Close()
out := []model.VaniPick{}
for rows.Next() {
var p model.VaniPick
var raw []byte
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
return nil, fmt.Errorf("scan vani pick: %w", err)
}
if err := json.Unmarshal(raw, &p.Payload); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// BookTitle resolves a book code to its display title (from book_meta.data.title),
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
@@ -135,6 +200,50 @@ 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
}
// 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) {
+4
View File
@@ -1162,6 +1162,10 @@ func (s *Server) setupRouter() {
r.Get("/disciples", s.adminH.ListDisciples)
// Announcements composed and published from the admin panel.
r.Get("/vani", s.vaniH.Schedule)
r.Post("/vani", s.vaniH.Set)
r.Delete("/vani/{day}", s.vaniH.Clear)
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
+54
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
@@ -204,3 +205,56 @@ func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
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")
}
}
}
+56
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"errors"
"fmt"
"math/rand"
"regexp"
@@ -31,6 +32,9 @@ 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.
// ErrVaniBadKind is a kind outside verse/prose/transcript.
var ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
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 +79,58 @@ 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.
// 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] })