349 lines
11 KiB
Go
349 lines
11 KiB
Go
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).
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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),
|
|
// 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
|
|
// as bson.D, so a `raw["data"].(bson.M)` assertion would silently fail).
|
|
func (r *VaniRepo) BookTitle(ctx context.Context, book string) string {
|
|
var doc struct {
|
|
Data struct {
|
|
Title string `bson:"title"`
|
|
} `bson:"data"`
|
|
}
|
|
if err := r.mongo.Collection("book_meta").
|
|
FindOne(ctx, bson.D{{Key: "book", Value: book}}).Decode(&doc); err != nil {
|
|
return ""
|
|
}
|
|
return doc.Data.Title
|
|
}
|
|
|
|
// --- 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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
// _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
|
|
}
|