feat: daily vani
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
Reference in New Issue
Block a user