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 }