diff --git a/internal/handler/search.go b/internal/handler/search.go index 2153a05..3b1a83d 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -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, } diff --git a/internal/handler/vani.go b/internal/handler/vani.go index 2d9f873..796c747 100644 --- a/internal/handler/vani.go +++ b/internal/handler/vani.go @@ -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 { diff --git a/internal/repository/mention_repo.go b/internal/repository/mention_repo.go index 9876848..40a4562 100644 --- a/internal/repository/mention_repo.go +++ b/internal/repository/mention_repo.go @@ -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`, diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index d488d9a..90685aa 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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 diff --git a/internal/repository/vani_repo.go b/internal/repository/vani_repo.go index 8fce91e..b3c34ba 100644 --- a/internal/repository/vani_repo.go +++ b/internal/repository/vani_repo.go @@ -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) { diff --git a/internal/server/server.go b/internal/server/server.go index 68804dc..83a2bf6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/service/gift_service_test.go b/internal/service/gift_service_test.go index aa93a57..983232e 100644 --- a/internal/service/gift_service_test.go +++ b/internal/service/gift_service_test.go @@ -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") + } + } +} diff --git a/internal/service/vani_service.go b/internal/service/vani_service.go index 1c38317..59217be 100644 --- a/internal/service/vani_service.go +++ b/internal/service/vani_service.go @@ -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] })