From 0fee56d272d208eb32f4b789e58fe4914a58ecfd Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Mon, 31 Aug 2026 13:39:56 +0530 Subject: [PATCH] feat: vani of the day edit --- internal/handler/vani.go | 34 +++++++++++- internal/repository/vani_repo.go | 57 +++++++++++++++++++++ internal/server/server.go | 1 + internal/service/vani_service.go | 88 +++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 3 deletions(-) diff --git a/internal/handler/vani.go b/internal/handler/vani.go index 796c747..5d24309 100644 --- a/internal/handler/vani.go +++ b/internal/handler/vani.go @@ -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. func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) { 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) { 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()) case errors.Is(err, repository.ErrVaniRefNotFound): response.Error(w, http.StatusNotFound, err.Error()) diff --git a/internal/repository/vani_repo.go b/internal/repository/vani_repo.go index b3c34ba..196a847 100644 --- a/internal/repository/vani_repo.go +++ b/internal/repository/vani_repo.go @@ -217,6 +217,63 @@ func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface 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 // chosen. The payload is then built by the same builders the random picker // uses, so a pinned pick renders identically to an automatic one. diff --git a/internal/server/server.go b/internal/server/server.go index 83a2bf6..172291b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1164,6 +1164,7 @@ func (s *Server) setupRouter() { // Announcements composed and published from the admin panel. r.Get("/vani", s.vaniH.Schedule) r.Post("/vani", s.vaniH.Set) + r.Post("/vani/by-url", s.vaniH.SetByURL) r.Delete("/vani/{day}", s.vaniH.Clear) r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) diff --git a/internal/service/vani_service.go b/internal/service/vani_service.go index 59217be..82e24a0 100644 --- a/internal/service/vani_service.go +++ b/internal/service/vani_service.go @@ -9,7 +9,9 @@ import ( "errors" "fmt" "math/rand" + "net/url" "regexp" + "strconv" "strings" "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 // 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") +var ( + // ErrVaniBadKind is a kind outside verse/prose/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) { 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 // 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āṇī. // // Takes only a kind and a content id — never a payload. The display blob is