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.
|
// searchResultItem represents a single search result in the API response.
|
||||||
type searchResultItem struct {
|
type searchResultItem struct {
|
||||||
Collection string `json:"collection"`
|
Collection string `json:"collection"`
|
||||||
Slug string `json:"slug,omitempty"`
|
// DocID is the underlying Mongo _id hex — the indexer uses it as the
|
||||||
Title string `json:"title"`
|
// Typesense document id. Surfaced so the admin panel can pin a specific
|
||||||
Snippet string `json:"snippet"`
|
// item as the Vāṇī of the Day; it identifies public content, not anything
|
||||||
URL string `json:"url"`
|
// private.
|
||||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
DocID string `json:"doc_id,omitempty"`
|
||||||
score int64 // internal, not serialized
|
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.
|
// 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{
|
item := searchResultItem{
|
||||||
Collection: collection,
|
Collection: collection,
|
||||||
|
DocID: getStringField(doc, "id"),
|
||||||
URL: getStringField(doc, "url"),
|
URL: getStringField(doc, "url"),
|
||||||
score: score,
|
score: score,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -18,6 +25,93 @@ func NewVaniHandler(svc *service.VaniService) *VaniHandler {
|
|||||||
|
|
||||||
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
||||||
// the current UTC day. Responds with `data: null` if there's no content.
|
// the current UTC day. Responds with `data: null` if there's no content.
|
||||||
|
type setVaniReq struct {
|
||||||
|
// Day as YYYY-MM-DD (UTC). Empty means today.
|
||||||
|
Day string `json:"day"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// RefID is the content's Mongo _id hex, which search returns as doc_id.
|
||||||
|
RefID string `json:"ref_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
|
||||||
|
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req setVaniReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
day, err := parseVaniDay(req.Day)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
|
||||||
|
if err != nil {
|
||||||
|
writeVaniError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"day": pick.Day.Format("2006-01-02"),
|
||||||
|
"kind": pick.Kind,
|
||||||
|
"ref_id": pick.RefID,
|
||||||
|
"payload": pick.Payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
||||||
payload, err := h.svc.Today(r.Context())
|
payload, err := h.svc.Today(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
|
|||||||
END AS rank
|
END AS rank
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id <> $1
|
WHERE u.id <> $1
|
||||||
|
-- A suspended account can't be @mentioned into a post.
|
||||||
|
AND u.banned_at IS NULL
|
||||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||||
ORDER BY rank, u.name, u.id
|
ORDER BY rank, u.name, u.id
|
||||||
LIMIT $4 OFFSET $5`,
|
LIMIT $4 OFFSET $5`,
|
||||||
|
|||||||
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
|||||||
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
||||||
var total int
|
var total int
|
||||||
if err := r.pg.Pool.QueryRow(ctx,
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
|
`SELECT COUNT(*) FROM users
|
||||||
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
|
||||||
substring).Scan(&total); err != nil {
|
substring).Scan(&total); err != nil {
|
||||||
return nil, 0, fmt.Errorf("count search users: %w", err)
|
return nil, 0, fmt.Errorf("count search users: %w", err)
|
||||||
}
|
}
|
||||||
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
|||||||
// and name could reshuffle between pages.
|
// and name could reshuffle between pages.
|
||||||
sql := `SELECT ` + userColumns + `
|
sql := `SELECT ` + userColumns + `
|
||||||
FROM users
|
FROM users
|
||||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
|
||||||
|
-- Suspended accounts are unsearchable. This one query backs the
|
||||||
|
-- people search page, the mention autocomplete AND both chat member
|
||||||
|
-- pickers, so a banned account appearing here is offered as someone
|
||||||
|
-- to @mention or add to a group. ResolveHandles already refuses them
|
||||||
|
-- on the way in, which without this makes "Add" silently do nothing.
|
||||||
|
AND banned_at IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN handle::text ILIKE $2 THEN 0
|
WHEN handle::text ILIKE $2 THEN 0
|
||||||
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
|
|||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.created_at >= $2
|
WHERE u.created_at >= $2
|
||||||
AND u.id <> $1
|
AND u.id <> $1
|
||||||
|
-- Suspended accounts are not discoverable. A ban hides someone from
|
||||||
|
-- the app; surfacing them in the welcome strip would be the most
|
||||||
|
-- prominent placement on the home page.
|
||||||
|
AND u.banned_at IS NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM follows f
|
SELECT 1 FROM follows f
|
||||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import (
|
|||||||
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||||
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||||
// verses / prose_chapters / transcriptions collections).
|
// verses / prose_chapters / transcriptions collections).
|
||||||
|
var (
|
||||||
|
// ErrVaniRefInvalid is a malformed content id.
|
||||||
|
ErrVaniRefInvalid = errors.New("not a valid content reference")
|
||||||
|
// ErrVaniRefNotFound is a well-formed id that matches nothing.
|
||||||
|
ErrVaniRefNotFound = errors.New("that content no longer exists")
|
||||||
|
)
|
||||||
|
|
||||||
type VaniRepo struct {
|
type VaniRepo struct {
|
||||||
pg *Postgres
|
pg *Postgres
|
||||||
mongo *MongoDB
|
mongo *MongoDB
|
||||||
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert writes a day's pick, REPLACING any existing one.
|
||||||
|
//
|
||||||
|
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
|
||||||
|
// picker must never overwrite a day already decided. An admin choosing the day
|
||||||
|
// deliberately is the one case that should win.
|
||||||
|
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
|
||||||
|
b, err := json.Marshal(pick.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
|
ON CONFLICT (day) DO UPDATE
|
||||||
|
SET kind = EXCLUDED.kind,
|
||||||
|
ref_id = EXCLUDED.ref_id,
|
||||||
|
payload = EXCLUDED.payload`,
|
||||||
|
pick.Day, pick.Kind, pick.RefID, b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert vani pick: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes a day's pick, so the automatic picker chooses again.
|
||||||
|
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear vani pick: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PicksFrom lists decided days from `from` onward, for the admin schedule.
|
||||||
|
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx,
|
||||||
|
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
|
||||||
|
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list vani picks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.VaniPick{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.VaniPick
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan vani pick: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p.Payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||||
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||||
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||||
@@ -135,6 +200,50 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
|
|||||||
return false, cur.Err()
|
return false, cur.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oneByHex loads a single document by its _id hex. Shared by the three
|
||||||
|
// admin-pick lookups, which differ only in collection and target type.
|
||||||
|
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
|
||||||
|
oid, err := bson.ObjectIDFromHex(hex)
|
||||||
|
if err != nil {
|
||||||
|
return ErrVaniRefInvalid
|
||||||
|
}
|
||||||
|
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
|
||||||
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
|
return ErrVaniRefNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load %s %s: %w", coll, hex, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
||||||
|
// chosen. The payload is then built by the same builders the random picker
|
||||||
|
// uses, so a pinned pick renders identically to an automatic one.
|
||||||
|
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
|
||||||
|
var v VerseDoc
|
||||||
|
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
|
||||||
|
var p ProseChapterDoc
|
||||||
|
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
|
||||||
|
var t TranscriptionDoc
|
||||||
|
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SampleVerse returns a random verse with a translation, excluding the given
|
// SampleVerse returns a random verse with a translation, excluding the given
|
||||||
// _id hexes. Returns nil when nothing matches.
|
// _id hexes. Returns nil when nothing matches.
|
||||||
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||||
|
|||||||
@@ -1162,6 +1162,10 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/disciples", s.adminH.ListDisciples)
|
r.Get("/disciples", s.adminH.ListDisciples)
|
||||||
|
|
||||||
// Announcements composed and published from the admin panel.
|
// 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.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||||
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||||
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -204,3 +205,56 @@ func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
|
|||||||
t.Errorf("the seeded reward was removed: %v", err)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"regexp"
|
"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
|
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||||
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||||
// content to pick.
|
// content to pick.
|
||||||
|
// 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) {
|
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
@@ -75,6 +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
|
// choose tries the three content kinds in random order and returns the first
|
||||||
// successful sample, so each day's pick varies across content types.
|
// successful sample, so each day's pick varies across content types.
|
||||||
|
// SetForDay pins a specific piece of content as a given day's Vāṇī.
|
||||||
|
//
|
||||||
|
// Takes only a kind and a content id — never a payload. The display blob is
|
||||||
|
// rebuilt here with the same builders the random picker uses, so a pinned pick
|
||||||
|
// is indistinguishable from an automatic one and a client cannot dictate what
|
||||||
|
// the home page says.
|
||||||
|
func (s *VaniService) SetForDay(ctx context.Context, day time.Time, kind, hex string) (*model.VaniPick, error) {
|
||||||
|
day = day.UTC().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
var pick *model.VaniPick
|
||||||
|
switch kind {
|
||||||
|
case "verse":
|
||||||
|
v, err := s.repo.VerseByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = versePick(v, day)
|
||||||
|
case "prose":
|
||||||
|
p, err := s.repo.ProseByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
||||||
|
case "transcript":
|
||||||
|
t, err := s.repo.TranscriptByHex(ctx, hex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pick = transcriptPick(t, day)
|
||||||
|
default:
|
||||||
|
return nil, ErrVaniBadKind
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Upsert(ctx, pick); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pick, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearForDay drops a pinned pick, handing the day back to the random picker.
|
||||||
|
func (s *VaniService) ClearForDay(ctx context.Context, day time.Time) error {
|
||||||
|
return s.repo.Clear(ctx, day.UTC().Truncate(24*time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule lists decided days from today onward, for the admin view.
|
||||||
|
func (s *VaniService) Schedule(ctx context.Context, limit int) ([]model.VaniPick, error) {
|
||||||
|
if limit <= 0 || limit > 60 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
return s.repo.PicksFrom(ctx, time.Now().UTC().Truncate(24*time.Hour), limit)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
||||||
kinds := []string{"verse", "prose", "transcript"}
|
kinds := []string{"verse", "prose", "transcript"}
|
||||||
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
||||||
|
|||||||
Reference in New Issue
Block a user