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 } // 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() } // 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 }