feat: vani of the day edit

This commit is contained in:
Krispire Admin
2026-08-31 13:39:56 +05:30
parent 44fd1d69ab
commit 0fee56d272
4 changed files with 177 additions and 3 deletions
+33 -1
View File
@@ -58,6 +58,36 @@ func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
}) })
} }
type setVaniByURLReq struct {
Day string `json:"day"`
URL string `json:"url"`
}
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
var req setVaniByURLReq
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.SetByURL(r.Context(), req.URL, day)
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. // Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) { func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
day, err := parseVaniDay(chi.URLParam(r, "day")) day, err := parseVaniDay(chi.URLParam(r, "day"))
@@ -103,7 +133,9 @@ func parseVaniDay(s string) (time.Time, error) {
func writeVaniError(w http.ResponseWriter, err error) { func writeVaniError(w http.ResponseWriter, err error) {
switch { switch {
case errors.Is(err, repository.ErrVaniRefInvalid), errors.Is(err, service.ErrVaniBadKind): case errors.Is(err, repository.ErrVaniRefInvalid),
errors.Is(err, service.ErrVaniBadKind),
errors.Is(err, service.ErrVaniBadURL):
response.Error(w, http.StatusBadRequest, err.Error()) response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrVaniRefNotFound): case errors.Is(err, repository.ErrVaniRefNotFound):
response.Error(w, http.StatusNotFound, err.Error()) response.Error(w, http.StatusNotFound, err.Error())
+57
View File
@@ -217,6 +217,63 @@ func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface
return nil return nil
} }
// TranscriptBySlug loads a lecture by its URL slug.
//
// Search cannot do this: the slug is not an indexed field, so querying
// "720720sb-london" matches on tokens like "london" and returns whatever else
// mentions it. Resolving the slug directly is the only reliable way to pin a
// specific item someone already has the URL for.
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
err := r.mongo.Collection("transcriptions").
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("transcript by slug: %w", err)
}
return &t, nil
}
// ProseBySlug loads a prose chapter by book and slug.
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
{Key: "book", Value: book},
{Key: "slug", Value: slug},
}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("prose by slug: %w", err)
}
return &p, nil
}
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
// which is what a verse URL carries.
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
filter := bson.D{
{Key: "book", Value: book},
{Key: "chapter", Value: chapter},
{Key: "verse", Value: verse},
}
if division != nil && *division != "" {
filter = append(filter, bson.E{Key: "division", Value: *division})
}
var v VerseDoc
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("verse by ref: %w", err)
}
return &v, nil
}
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has // VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
// chosen. The payload is then built by the same builders the random picker // chosen. The payload is then built by the same builders the random picker
// uses, so a pinned pick renders identically to an automatic one. // uses, so a pinned pick renders identically to an automatic one.
+1
View File
@@ -1164,6 +1164,7 @@ func (s *Server) setupRouter() {
// Announcements composed and published from the admin panel. // Announcements composed and published from the admin panel.
r.Get("/vani", s.vaniH.Schedule) r.Get("/vani", s.vaniH.Schedule)
r.Post("/vani", s.vaniH.Set) r.Post("/vani", s.vaniH.Set)
r.Post("/vani/by-url", s.vaniH.SetByURL)
r.Delete("/vani/{day}", s.vaniH.Clear) r.Delete("/vani/{day}", s.vaniH.Clear)
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
+85 -1
View File
@@ -9,7 +9,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -32,8 +34,12 @@ 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.
var (
// ErrVaniBadKind is a kind outside verse/prose/transcript. // ErrVaniBadKind is a kind outside verse/prose/transcript.
var ErrVaniBadKind = errors.New("kind must be verse, prose or transcript") ErrVaniBadKind = errors.New("kind must be verse, prose or transcript")
// ErrVaniBadURL is a link that isn't a verse, prose chapter or lecture.
ErrVaniBadURL = errors.New("that link isn't a verse, chapter or lecture")
)
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()
@@ -79,6 +85,84 @@ 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.
// ResolveURL turns an app URL (or bare path) into a pinnable pick.
//
// The intended admin workflow: paste the link to the thing you want. Search
// cannot serve this — slugs are not indexed, so searching "720720sb-london"
// matches on "london" and returns unrelated lectures.
//
// Accepts an absolute URL or a path, and understands the three shapes the
// vāṇī can point at:
//
// /transcriptions/{slug}
// /books/{book}/{chapter}/{verse} (+ /books/{book}/{division}/{c}/{v})
// /books/{book}/{slug} (prose chapter)
func (s *VaniService) ResolveURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
day = day.UTC().Truncate(24 * time.Hour)
path := strings.TrimSpace(raw)
if u, err := url.Parse(path); err == nil && u.Host != "" {
path = u.Path
}
path = strings.Trim(strings.SplitN(path, "?", 2)[0], "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, ErrVaniBadURL
}
switch parts[0] {
case "transcriptions":
t, err := s.repo.TranscriptBySlug(ctx, parts[1])
if err != nil {
return nil, err
}
return transcriptPick(t, day), nil
case "books":
book := parts[1]
nums := parts[2:]
// A trailing division segment (SB cantos, CC līlās) shifts the
// chapter/verse pair along by one.
var division *string
if len(nums) == 3 {
d := nums[0]
division, nums = &d, nums[1:]
}
if len(nums) == 2 {
chapter, err1 := strconv.Atoi(nums[0])
verse, err2 := strconv.Atoi(nums[1])
if err1 == nil && err2 == nil {
v, err := s.repo.VerseByRef(ctx, book, division, chapter, verse)
if err != nil {
return nil, err
}
return versePick(v, day), nil
}
}
// Not numeric, so it is a prose chapter's bare slug.
if len(nums) == 1 {
p, err := s.repo.ProseBySlug(ctx, book, nums[0])
if err != nil {
return nil, err
}
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day), nil
}
}
return nil, ErrVaniBadURL
}
// SetByURL resolves a URL and pins the result.
func (s *VaniService) SetByURL(ctx context.Context, raw string, day time.Time) (*model.VaniPick, error) {
pick, err := s.ResolveURL(ctx, raw, day)
if err != nil {
return nil, err
}
if err := s.repo.Upsert(ctx, pick); err != nil {
return nil, err
}
return pick, nil
}
// SetForDay pins a specific piece of content as a given day's Vāṇī. // 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 // Takes only a kind and a content id — never a payload. The display blob is