diff --git a/internal/handler/vani.go b/internal/handler/vani.go new file mode 100644 index 0000000..2d9f873 --- /dev/null +++ b/internal/handler/vani.go @@ -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) +} diff --git a/internal/model/vani.go b/internal/model/vani.go new file mode 100644 index 0000000..dea1a97 --- /dev/null +++ b/internal/model/vani.go @@ -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 `:` and +// is what the 90-day no-repeat rule dedupes on. +type VaniPick struct { + Day time.Time + Kind string + RefID string + Payload VaniPayload +} diff --git a/internal/repository/vani_repo.go b/internal/repository/vani_repo.go new file mode 100644 index 0000000..0dc9795 --- /dev/null +++ b/internal/repository/vani_repo.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index ef2385e..db7f6a3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/service/vani_service.go b/internal/service/vani_service.go new file mode 100644 index 0000000..8d7519d --- /dev/null +++ b/internal/service/vani_service.go @@ -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])) + "…" +} diff --git a/migrations/0044_create_vani_of_the_day.down.sql b/migrations/0044_create_vani_of_the_day.down.sql new file mode 100644 index 0000000..91afa66 --- /dev/null +++ b/migrations/0044_create_vani_of_the_day.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS vani_of_the_day; diff --git a/migrations/0044_create_vani_of_the_day.up.sql b/migrations/0044_create_vani_of_the_day.up.sql new file mode 100644 index 0000000..f0b5ed6 --- /dev/null +++ b/migrations/0044_create_vani_of_the_day.up.sql @@ -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` +-- (`:`) 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);