feat: daily vani

This commit is contained in:
Krispire Admin
2026-07-11 00:31:22 +05:30
parent 9c9c46caf1
commit c26904b61e
7 changed files with 490 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// VaniHandler serves the public "Vāṇī of the Day" pick for the home page.
type VaniHandler struct {
svc *service.VaniService
}
func NewVaniHandler(svc *service.VaniService) *VaniHandler {
return &VaniHandler{svc: svc}
}
// 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.
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
payload, err := h.svc.Today(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load vāṇī of the day")
return
}
response.JSON(w, http.StatusOK, payload)
}
+24
View File
@@ -0,0 +1,24 @@
package model
import "time"
// VaniPayload is the prebuilt display blob for the "Vāṇī of the Day" CTA. It's
// owned by the frontend's rendering needs and stored verbatim as JSONB, so the
// home page can render without re-reading Mongo.
type VaniPayload struct {
Kind string `json:"kind"` // "verse" | "prose" | "transcript"
Label string `json:"label"` // "Verse" | "Chapter" | "Lecture" | "Conversation"
Title string `json:"title"` // reference / chapter title / lecture name
Subtitle string `json:"subtitle"` // short excerpt or date · place
Href string `json:"href"` // in-app path to open the item
BookTitle string `json:"book_title,omitempty"` // e.g. "Bhagavad-gītā As It Is"
}
// VaniPick is one persisted day's choice. RefID is `<kind>:<mongo _id hex>` and
// is what the 90-day no-repeat rule dedupes on.
type VaniPick struct {
Day time.Time
Kind string
RefID string
Payload VaniPayload
}
+165
View File
@@ -0,0 +1,165 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// 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).
type VaniRepo struct {
pg *Postgres
mongo *MongoDB
}
func NewVaniRepo(pg *Postgres, m *MongoDB) *VaniRepo {
return &VaniRepo{pg: pg, mongo: m}
}
// --- Postgres: the per-day record ---
// GetForDay returns the pick stored for the given UTC day, or (nil, nil) when
// nothing has been chosen yet.
func (r *VaniRepo) GetForDay(ctx context.Context, day time.Time) (*model.VaniPick, error) {
var kind, refID string
var payload []byte
err := r.pg.Pool.QueryRow(ctx,
`SELECT kind, ref_id, payload FROM vani_of_the_day WHERE day = $1`, day,
).Scan(&kind, &refID, &payload)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get vani for day: %w", err)
}
var p model.VaniPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
return &model.VaniPick{Day: day, Kind: kind, RefID: refID, Payload: p}, nil
}
// RecentRefIDs returns the ref_ids chosen on or after `since` — the 90-day
// exclusion set for the picker.
func (r *VaniRepo) RecentRefIDs(ctx context.Context, since time.Time) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT ref_id FROM vani_of_the_day WHERE day >= $1`, since)
if err != nil {
return nil, fmt.Errorf("recent vani ref ids: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// Insert stores the day's pick, ignoring a race where another request already
// wrote today's (ON CONFLICT (day) DO NOTHING). Callers re-read GetForDay to
// get the authoritative row.
func (r *VaniRepo) Insert(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 NOTHING`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("insert vani pick: %w", err)
}
return nil
}
// --- Mongo: random candidate sampling ---
// excludeIDs turns hex _id strings into a $nin ObjectID array.
func excludeIDs(hexes []string) bson.A {
arr := bson.A{}
for _, h := range hexes {
if oid, err := bson.ObjectIDFromHex(h); err == nil {
arr = append(arr, oid)
}
}
return arr
}
func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out interface{}) (bool, error) {
cur, err := r.mongo.Collection(coll).Aggregate(ctx, mongo.Pipeline{
bson.D{{Key: "$match", Value: match}},
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: 1}}}},
})
if err != nil {
return false, fmt.Errorf("sample %s: %w", coll, err)
}
defer cur.Close(ctx)
if cur.Next(ctx) {
if err := cur.Decode(out); err != nil {
return false, err
}
return true, nil
}
return false, cur.Err()
}
// 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) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "translation", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$ne", Value: ""}}},
}
var d VerseDoc
ok, err := r.sampleOne(ctx, "verses", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
// SampleProse returns a random prose chapter with body text, excluding the
// given _id hexes.
func (r *VaniRepo) SampleProse(ctx context.Context, excludeHex []string) (*ProseChapterDoc, error) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "paragraphs.0", Value: bson.D{{Key: "$exists", Value: true}}},
}
var d ProseChapterDoc
ok, err := r.sampleOne(ctx, "prose_chapters", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
// SampleTranscript returns a random published transcript with content,
// excluding the given _id hexes.
func (r *VaniRepo) SampleTranscript(ctx context.Context, excludeHex []string) (*TranscriptionDoc, error) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "isPublished", Value: true},
{Key: "content.0", Value: bson.D{{Key: "$exists", Value: true}}},
}
var d TranscriptionDoc
ok, err := r.sampleOne(ctx, "transcriptions", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
+10
View File
@@ -49,6 +49,7 @@ type Server struct {
otherDocH *handler.OtherDocumentHandler
transcriptionH *handler.TranscriptionHandler
searchH *handler.SearchHandler
vaniH *handler.VaniHandler
sitemapH *handler.SitemapHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
@@ -200,6 +201,12 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
// page, chosen from Mongo content and recorded in Postgres so it doesn't
// repeat within 90 days.
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// 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.
@@ -647,6 +654,9 @@ func (s *Server) setupRouter() {
// Search (Typesense)
r.Get("/search", s.searchH.Search)
// Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today)
// --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
+242
View File
@@ -0,0 +1,242 @@
// Package service — VaniService powers "Vāṇī of the Day": one piece of content
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
// each day. The pick is chosen once per UTC day and reused for everyone; the
// picker avoids anything served in the last 90 days.
package service
import (
"context"
"fmt"
"math/rand"
"regexp"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
const vaniNoRepeatDays = 90
type VaniService struct {
repo *repository.VaniRepo
logger *zap.Logger
}
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
return &VaniService{repo: repo, logger: logger}
}
// 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.
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)
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
return nil, err
} else if existing != nil {
return &existing.Payload, nil
}
// Build the 90-day exclusion set, bucketed by kind.
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
if err != nil {
return nil, err
}
exclude := map[string][]string{}
for _, rid := range recent {
if kind, hex, ok := splitRef(rid); ok {
exclude[kind] = append(exclude[kind], hex)
}
}
pick := s.choose(ctx, exclude, day)
if pick == nil {
// Everything in some kind exhausted the 90-day window? Relax it.
pick = s.choose(ctx, map[string][]string{}, day)
}
if pick == nil {
return nil, nil
}
if err := s.repo.Insert(ctx, pick); err != nil {
s.logger.Warn("vani: insert failed", zap.Error(err))
}
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
// consistently for everyone.
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
return &stored.Payload, nil
}
return &pick.Payload, nil
}
// choose tries the three content kinds in random order and returns the first
// successful sample, so each day's pick varies across content types.
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] })
for _, kind := range kinds {
switch kind {
case "verse":
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
return versePick(v, day)
}
case "prose":
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
return prosePick(p, day)
}
case "transcript":
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
return transcriptPick(t, day)
}
}
}
return nil
}
// --- Payload builders ---
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
title := v.Reference
if title == "" {
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
}
return &model.VaniPick{
Day: day,
Kind: "verse",
RefID: "verse:" + v.ID.Hex(),
Payload: model.VaniPayload{
Kind: "verse",
Label: "Verse",
Title: title,
Subtitle: excerpt(v.Translation, 140),
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
BookTitle: v.BookTitle,
},
}
}
func prosePick(p *repository.ProseChapterDoc, day time.Time) *model.VaniPick {
first := ""
if len(p.Paragraphs) > 0 {
first = p.Paragraphs[0]
}
return &model.VaniPick{
Day: day,
Kind: "prose",
RefID: "prose:" + p.ID.Hex(),
Payload: model.VaniPayload{
Kind: "prose",
Label: "Chapter",
Title: p.Title,
Subtitle: excerpt(first, 140),
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
},
}
}
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
label := "Lecture"
if t.Type == "conversation" {
label = "Conversation"
}
// Prefer a date · place line; fall back to the first spoken line.
var bits []string
if t.Date != nil && *t.Date != "" {
bits = append(bits, *t.Date)
}
if t.Place != nil && *t.Place != "" {
bits = append(bits, *t.Place)
}
subtitle := strings.Join(bits, " · ")
if subtitle == "" && len(t.Content) > 0 {
subtitle = excerpt(t.Content[0].Text, 140)
}
return &model.VaniPick{
Day: day,
Kind: "transcript",
RefID: "transcript:" + t.ID.Hex(),
Payload: model.VaniPayload{
Kind: "transcript",
Label: label,
Title: t.Name,
Subtitle: subtitle,
Href: "/transcriptions/" + t.Slug,
},
}
}
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
func verseURL(book string, division *string, chapter, verse int) string {
switch book {
case "bg":
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
case "sb":
if division != nil {
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
case "cc":
if division != nil {
return fmt.Sprintf("/books/cc/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
case "sb1962":
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
case "iso":
return fmt.Sprintf("/books/iso/%d", chapter)
case "noi":
return fmt.Sprintf("/books/noi/%d", chapter)
case "bs":
return fmt.Sprintf("/books/bs/5/%d", verse)
case "mm":
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
case "nbs":
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
default:
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
}
}
func proseURL(book string, volume int, docType, slug string) string {
switch {
case book == "kb":
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
case docType == "chapter-summary":
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
}
return fmt.Sprintf("/books/%s/%s", book, slug)
default:
return fmt.Sprintf("/books/%s/%s", book, slug)
}
}
// --- helpers ---
func splitRef(ref string) (kind, hex string, ok bool) {
if i := strings.IndexByte(ref, ':'); i > 0 {
return ref[:i], ref[i+1:], true
}
return "", "", false
}
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
var wsRe = regexp.MustCompile(`\s+`)
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
// ellipsis — a clean one-line teaser for the CTA.
func excerpt(html string, n int) string {
s := htmlTagRe.ReplaceAllString(html, "")
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS vani_of_the_day;
@@ -0,0 +1,20 @@
-- "Vāṇī of the Day" — one randomly-chosen piece of content (a prose chapter,
-- a verse, or a transcript/lecture) surfaced on the home page each day.
--
-- One row per calendar day (UTC). The pick is chosen once — the first request
-- of the day writes it and everyone sees the same item that day. `ref_id`
-- (`<kind>:<mongo _id hex>`) identifies the underlying content so the picker
-- can avoid repeating anything served in the last 90 days. `payload` is the
-- prebuilt display blob (title/subtitle/href/…) so the CTA renders without
-- re-reading Mongo.
CREATE TABLE IF NOT EXISTS vani_of_the_day (
day DATE PRIMARY KEY,
kind TEXT NOT NULL,
ref_id TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Serves the "not in the last 90 days" lookup (scan recent rows by ref_id).
CREATE INDEX IF NOT EXISTS vani_of_the_day_ref_day_idx
ON vani_of_the_day (ref_id, day DESC);