fix: hide suspended account
This commit is contained in:
@@ -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,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 {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] })
|
||||
|
||||
Reference in New Issue
Block a user